Skip to content

Commit d51ba8f

Browse files
committed
Rewrite the image builder in Golang
1 parent 032a164 commit d51ba8f

File tree

5 files changed

+356
-5
lines changed

5 files changed

+356
-5
lines changed

Dockerfile

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,27 @@ RUN \
2424
--mount=type=cache,target=/root/.cache/go-build \
2525
CGO_ENABLED=0 go build -o /go/bin/init -v --ldflags '-s -w -extldflags=-static'
2626

27+
# =========================================================
28+
FROM alpine:edge as systemd-efistub
29+
RUN apk add --no-cache systemd-efistub
30+
31+
# =========================================================
32+
FROM golang:1.24.3-alpine AS imager-build
33+
COPY --from=systemd-efistub /usr/lib/systemd/boot/efi/linux*.efi.stub /usr/lib/systemd/boot/efi/
34+
WORKDIR /go/src
35+
RUN \
36+
--mount=source=imager,target=. \
37+
--mount=type=cache,target=/root/.cache/go-build \
38+
CGO_ENABLED=0 go build -o /go/bin/imager -v --ldflags '-s -w -extldflags=-static'
39+
2740
# =========================================================
2841
FROM alpine:3.21.3 AS alpine-base
2942

3043
# =========================================================
44+
# FROM scratch AS imager
3145
FROM alpine-base AS imager
3246
SHELL ["/bin/ash", "-euxo", "pipefail", "-c"]
3347
RUN \
34-
echo "@edge-community https://dl-cdn.alpinelinux.org/alpine/edge/community" >>/etc/apk/repositories && \
3548
apk add --no-cache \
3649
bash \
3750
binutils \
@@ -45,12 +58,11 @@ qemu-img \
4558
sfdisk \
4659
xorriso \
4760
zstd \
48-
xz \
49-
systemd-efistub@edge-community
61+
xz
5062
COPY --from=init /go/bin/init /usr/share/claylinux/init
51-
COPY build-image.sh /usr/bin/build-image
63+
COPY --from=imager-build /go/bin/imager /bin/imager
5264
WORKDIR /out
53-
ENTRYPOINT ["build-image"]
65+
ENTRYPOINT ["/bin/imager"]
5466

5567
# =========================================================
5668
FROM alpine-base AS bootable-alpine-rootfs
File renamed without changes.

imager/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/sprat/claylinux/imager
2+
3+
go 1.24
4+
5+
require golang.org/x/sys v0.33.0

imager/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
2+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

imager/main.go

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os"
7+
"runtime"
8+
"strings"
9+
)
10+
11+
var availableEfiArchectures = map[string]string{
12+
"386": "IA32",
13+
"amd64": "X64",
14+
"arm": "ARM",
15+
"arm64": "AA64",
16+
}
17+
18+
// --- Helper functions ---
19+
func EfiArchitecture() (string, error) {
20+
// other architectures are not available in systemd EFI stub
21+
arch, found := availableEfiArchectures[runtime.GOARCH]
22+
if !found {
23+
return "", fmt.Errorf("unsupported EFI architecture")
24+
}
25+
return arch, nil
26+
}
27+
28+
/*
29+
func FileSize(path string) (int64, error) {
30+
info, err := os.Stat(path)
31+
if err != nil {
32+
return 0, fmt.Errorf("could not stat file %s: %v", path, err)
33+
}
34+
return info.Size(), nil
35+
}
36+
37+
func inMib(n int64) int64 {
38+
return (n + (1 << 20) - 1) >> 20
39+
}
40+
41+
func FileSizeMib(path string) (int64, error) {
42+
size, err := getFileSize(path)
43+
return inMib(size), err
44+
}
45+
46+
func align(val, multiple int64) int64 {
47+
return ((val + multiple - 1) / multiple) * multiple
48+
}
49+
50+
func run(name string, args ...string) {
51+
cmd := exec.Command(name, args...)
52+
cmd.Stdout = os.Stdout
53+
cmd.Stderr = os.Stderr
54+
if err := cmd.Run(); err != nil {
55+
die(fmt.Sprintf("command failed: %s %v: %v", name, args, err))
56+
}
57+
}
58+
59+
func runOutput(name string, args ...string) string {
60+
cmd := exec.Command(name, args...)
61+
out, err := cmd.Output()
62+
if err != nil {
63+
die(fmt.Sprintf("command failed: %s %v: %v", name, args, err))
64+
}
65+
return strings.TrimSpace(string(out))
66+
}
67+
68+
// --- Core build steps ---
69+
func compress(filename string) {
70+
switch compression {
71+
case "none":
72+
// do nothing
73+
case "gz":
74+
run("pigz", "-9", filename)
75+
run("mv", filename+".gz", filename)
76+
case "xz":
77+
run("xz", "-C", "crc32", "-9", "-T0", filename)
78+
run("mv", filename+".xz", filename)
79+
case "zstd":
80+
run("zstd", "-19", "-T0", "--rm", filename)
81+
run("mv", filename+".zstd", filename)
82+
default:
83+
die("invalid compression scheme: " + compression)
84+
}
85+
}
86+
87+
// --- Build steps ---
88+
89+
func buildInitramfs() {
90+
os.Mkdir("initramfs_files", 0755)
91+
run("cp", "/usr/share/claylinux/init", "initramfs_files")
92+
93+
if fileExists("/system/etc/hosts.target") {
94+
os.MkdirAll("initramfs_files/etc", 0755)
95+
run("cp", "/system/etc/hosts.target", "initramfs_files/etc/hosts")
96+
}
97+
if fileExists("/system/etc/resolv.conf.target") {
98+
os.MkdirAll("initramfs_files/etc", 0755)
99+
run("cp", "/system/etc/resolv.conf.target", "initramfs_files/etc/resolv.conf")
100+
}
101+
// cpio for initramfs_files
102+
run("sh", "-c", `find initramfs_files -mindepth 1 -printf '%P\0' | cpio --quiet -o0H newc -D initramfs_files -F initramfs.img`)
103+
// Add system files except boot, hosts.target, resolv.conf.target
104+
run("sh", "-c", `find /system -path /system/boot -prune -o ! -path /system/init ! -path /system/etc/hosts.target ! -path /system/etc/resolv.conf.target -mindepth 1 -printf '%P\0' | cpio --quiet -o0AH newc -D /system -F initramfs.img`)
105+
compress("initramfs.img")
106+
107+
ucode := runOutput("find", "/system/boot/", "-name", "*-ucode.img")
108+
imgs := "initramfs.img"
109+
if ucode != "" {
110+
imgs = ucode + " " + imgs
111+
}
112+
run("sh", "-c", fmt.Sprintf("cat %s >initramfs", imgs))
113+
run("find", ".", "!", "-name", "initramfs", "-delete")
114+
}
115+
116+
// getInitialOffset computes offset+size from objdump output.
117+
func getInitialOffset(efiStub string) (int64, error) {
118+
cmd := exec.Command("objdump", "-h", "-w", efiStub)
119+
out, err := cmd.Output()
120+
if err != nil {
121+
return 0, err
122+
}
123+
scanner := bufio.NewScanner(bytes.NewReader(out))
124+
var lastFields []string
125+
for scanner.Scan() {
126+
line := scanner.Text()
127+
// Sections have lines that start with space+number or name, skip headers
128+
fields := strings.Fields(line)
129+
if len(fields) >= 5 && strings.HasPrefix(fields[1], "0x") {
130+
lastFields = fields
131+
}
132+
}
133+
if len(lastFields) < 5 {
134+
return 0, fmt.Errorf("failed to parse objdump output")
135+
}
136+
offset, err := strconv.ParseInt(lastFields[4], 0, 64)
137+
if err != nil {
138+
return 0, err
139+
}
140+
size, err := strconv.ParseInt(lastFields[3], 0, 64)
141+
if err != nil {
142+
return 0, err
143+
}
144+
return int64(offset + size), nil
145+
}
146+
147+
func buildUKI() {
148+
const alignment = 4096
149+
efiStub := "path/to/efi_stub" // Set your input stub
150+
efiFile := "path/to/efi_file" // Set your output file
151+
152+
// For example, sections := [][2]string{{".foo", "foo.bin"}, {".bar", "bar.bin"}}
153+
sections := [][2]string{
154+
{".section1", "file1.bin"},
155+
{".section2", "file2.bin"},
156+
// Add more as needed
157+
}
158+
159+
// Step 1: Get initial offset from objdump and align it
160+
offset, err := getInitialOffset(efiStub)
161+
if err != nil {
162+
fmt.Fprintf(os.Stderr, "Error getting initial offset: %v\n", err)
163+
os.Exit(1)
164+
}
165+
offset = align(offset, alignment)
166+
167+
// Step 2: Prepare objcopy arguments
168+
var args []string
169+
for _, pair := range sections {
170+
section, file := pair[0], pair[1]
171+
args = append(args, "--add-section", fmt.Sprintf("%s=%s", section, file))
172+
args = append(args, "--change-section-vma", fmt.Sprintf("%s=0x%X", section, offset))
173+
174+
size, err := getFileSize(file)
175+
if err != nil {
176+
fmt.Fprintf(os.Stderr, "Error getting size of %s: %v\n", file, err)
177+
os.Exit(1)
178+
}
179+
size = align(size, alignment)
180+
offset += size
181+
}
182+
183+
// Step 3: Run objcopy
184+
args = append(args, efiStub, efiFile)
185+
cmd := exec.Command("objcopy", args...)
186+
cmd.Stdout = os.Stdout
187+
cmd.Stderr = os.Stderr
188+
fmt.Printf("Running: objcopy %s\n", strings.Join(args, " "))
189+
if err := cmd.Run(); err != nil {
190+
fmt.Fprintf(os.Stderr, "objcopy failed: %v\n", err)
191+
os.Exit(1)
192+
}
193+
194+
}
195+
196+
func buildEFI() {
197+
os.Chdir(buildDir)
198+
fmt.Println("Building the EFI executable")
199+
buildInitramfs()
200+
size := getFileSizeMib("initramfs")
201+
fmt.Printf("The size of the initramfs is: %d MiB\n", size)
202+
kernel := runOutput("find", "/system/boot", "-name", "vmlinu*", "-print")
203+
run("sh", "-c", "tr '\\n' ' ' </system/boot/cmdline >cmdline")
204+
run("sh", "-c", "basename /system/lib/modules/* >kernel-release")
205+
206+
// Compose the UKI section file
207+
ukiStanza := fmt.Sprintf(
208+
".osrel /system/etc/os-release\n.uname kernel-release\n.cmdline cmdline\n.initrd initramfs\n.linux %s\n", kernel)
209+
cmd := exec.Command("build_uki")
210+
cmd.Stdin = strings.NewReader(ukiStanza)
211+
if err := cmd.Run(); err != nil {
212+
die("build_uki failed: " + err.Error())
213+
}
214+
215+
run("find", ".", "!", "-name", "*.efi", "-delete")
216+
}
217+
218+
func generateEFI() {
219+
fmt.Println("Copying the OS files to the output directory")
220+
run("mv", efiFile, output+".efi")
221+
}
222+
223+
func generateESP() {
224+
fmt.Println("Generating the EFI System Partition (ESP)")
225+
size := getFileSize(efiFile)
226+
size = inMib(size * 102 / 100)
227+
fmt.Printf("The size of the ESP is: %d MiB\n", size)
228+
run("mkfs.vfat", "-n", volume, "-F", "32", "-C", espFile, strconv.FormatInt(size<<10, 10))
229+
run("mmd", "-i", espFile, "::/EFI")
230+
run("mmd", "-i", espFile, "::/EFI/boot")
231+
run("mcopy", "-i", espFile, efiFile, "::/EFI/boot/boot"+efiArch+".efi")
232+
run("rm", efiFile)
233+
}
234+
235+
func generateISO() {
236+
generateESP()
237+
fmt.Println("Generating the ISO image")
238+
run("xorrisofs", "-e", filepath.Base(espFile),
239+
"-no-emul-boot", "-joliet", "-full-iso9660-filenames", "-rational-rock",
240+
"-sysid", "LINUX", "-volid", volume,
241+
"-output", output+".iso", espFile)
242+
run("rm", espFile)
243+
}
244+
245+
func generateRaw() {
246+
generateESP()
247+
fmt.Println("Generating the disk image")
248+
diskFile := output + ".img"
249+
espMiB := getFileSizeMib(espFile)
250+
diskSize := espMiB + 2
251+
fmt.Printf("The size of the disk image is: %d MiB\n", diskSize)
252+
run("truncate", "-s", fmt.Sprintf("%dM", diskSize), diskFile)
253+
sfdiskCmd := fmt.Sprintf("label: gpt\nfirst-lba: 34\nstart=1MiB size=%dMiB name=\"EFI system partition\" type=uefi\n", espMiB)
254+
cmd := exec.Command("sfdisk", "--quiet", diskFile)
255+
cmd.Stdin = strings.NewReader(sfdiskCmd)
256+
if err := cmd.Run(); err != nil {
257+
die("sfdisk failed: " + err.Error())
258+
}
259+
run("dd", "if="+espFile, "of="+diskFile, "bs=1M", "seek=1", "conv=notrunc", "status=none")
260+
run("rm", espFile)
261+
}
262+
263+
func convertImage(format string) {
264+
generateRaw()
265+
fmt.Printf("Converting the disk image to %s format\n", format)
266+
run("qemu-img", "convert", "-f", "raw", "-O", format, output+".img", output+"."+format)
267+
run("rm", output+".img")
268+
}
269+
*/
270+
271+
// --- Option parsing, main ---
272+
func main() {
273+
/*
274+
output = "/out/claylinux"
275+
format = "raw"
276+
volume = "CLAYLINUX"
277+
compression = "gz"
278+
279+
flag.StringVar(&format, "format", format, "Output format (efi, iso, raw, qcow2, vmdk, vhdx, vdi)")
280+
flag.StringVar(&output, "output", output, "Output image path/name, without extension")
281+
flag.StringVar(&volume, "volume", volume, "Volume label for the boot partition")
282+
flag.StringVar(&compression, "compression", compression, "Compression format for initramfs: none|gz|xz|zstd")
283+
flag.Parse()
284+
285+
if _, err := os.Stat("/system"); err != nil {
286+
die("the /system directory does not exist, please copy/mount your root filesystem here")
287+
}
288+
*/
289+
290+
efiArch, err := EfiArchitecture()
291+
if err != nil {
292+
log.Fatal(err)
293+
} else {
294+
log.Printf("EFI architecture: %s", efiArch)
295+
}
296+
297+
efiStub := fmt.Sprintf("/usr/lib/systemd/boot/efi/linux%s.efi.stub", strings.ToLower(efiArch))
298+
log.Printf("EFI stub: %s", efiStub)
299+
300+
defaultEfiBinaryName := fmt.Sprintf("BOOT%s.EFI", efiArch)
301+
log.Printf("Default EFI binary name: %s", defaultEfiBinaryName)
302+
303+
buildDir, err := os.MkdirTemp("", "claylinux-imager-")
304+
if err != nil {
305+
log.Fatal(err)
306+
}
307+
log.Printf("Temporary build directory: %s", buildDir)
308+
defer os.RemoveAll(buildDir)
309+
310+
/*
311+
efiFile = filepath.Join(buildDir, "claylinux.efi")
312+
espFile = filepath.Join(buildDir, "claylinux.esp")
313+
314+
os.Chdir(buildDir)
315+
buildEFI()
316+
os.Chdir("/")
317+
318+
os.MkdirAll(filepath.Dir(output), 0755)
319+
switch format {
320+
case "efi":
321+
generateEFI()
322+
case "iso":
323+
generateISO()
324+
case "raw":
325+
generateRaw()
326+
case "qcow2", "vmdk", "vhdx", "vdi":
327+
convertImage(format)
328+
default:
329+
die("invalid format: " + format)
330+
}
331+
*/
332+
}

0 commit comments

Comments
 (0)