Skip to content

Commit af7f4d0

Browse files
authored
Add Azure Linux 4.0 ISO LiveOS bootstrap support (#816)
This enables Azure Linux 4.0 ISO (LiveOS) bootstrap and full-OS output. It was previously gated off because Azure Linux 4.0 uses Fedora-style BLS bootloader packaging that the LiveOS ISO builder did not handle, and this change adds distro-aware LiveOS hooks on `DistroHandler` to support it. **What changed** - Distro-aware LiveOS hooks on `DistroHandler` (required packages, grub EFI prefix directory, initrd dracut modules) for acl, azurelinux, azurelinux4, fedora, and ubuntu, plus the BLS grub.cfg and PXE boot-config helpers. - BLS-layout builder fixes: preserve each loader entry's `boot/loader/entries` subpath when staging, skip dangling module symlinks such as `symvers` when building the bootstrap initrd, write a grub prefix redirector so Azure Linux 4.0's `EFI/azurelinux`-prefixed grub finds its config, preserve each grub `search --set=<target>` when relabeling for the ISO volume id, and fall back to the input ISO's distro handler when repackaging an ISO without OS customization. - Omit the `selinux` dracut module for Fedora and Azure Linux 4.0, whose policy loads after `switch_root`. - Add the `dracut-live` package to the Azure Linux 4.0 bootstrap config and unskip the Azure Linux 4.0 ISO vmtests. PXE output stays explicitly unsupported for Azure Linux 4.0. That follows in a separate change.
1 parent 24fff9c commit af7f4d0

18 files changed

Lines changed: 739 additions & 181 deletions

test/vmtests/vmtests/imagecustomizer/test_min_change.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,6 @@ def test_min_change_efi_azl3_iso_bootstrap_output(
417417
)
418418

419419

420-
@pytest.mark.skip(reason="Azure Linux 4.0 ISO bootstrap output is not yet supported")
421420
def test_min_change_efi_azl4_iso_bootstrap_output(
422421
docker_client: DockerClient,
423422
image_customizer_container_url: str,
@@ -431,6 +430,7 @@ def test_min_change_efi_azl4_iso_bootstrap_output(
431430
) -> None:
432431
azl_release = 4
433432
config_path = TEST_CONFIGS_DIR.joinpath("iso-bootstrap-vm-azl4.yaml")
433+
config_path = add_preview_features_to_config(config_path, "preview-distro-version", close_list)
434434
output_format = "iso"
435435

436436
run_min_change_test(
@@ -480,7 +480,6 @@ def test_min_change_efi_azl3_iso_full_os_output(
480480
)
481481

482482

483-
@pytest.mark.skip(reason="Azure Linux 4.0 ISO full OS output is not yet supported")
484483
def test_min_change_efi_azl4_iso_full_os_output(
485484
docker_client: DockerClient,
486485
image_customizer_container_url: str,
@@ -494,6 +493,7 @@ def test_min_change_efi_azl4_iso_full_os_output(
494493
) -> None:
495494
azl_release = 4
496495
config_path = TEST_CONFIGS_DIR.joinpath("iso-full-os-vm-azl4.yaml")
496+
config_path = add_preview_features_to_config(config_path, "preview-distro-version", close_list)
497497
output_format = "iso"
498498

499499
run_min_change_test(
@@ -577,7 +577,6 @@ def test_min_change_legacy_azl3_iso_output(
577577

578578

579579
@pytest.mark.skipif(platform.machine() != "x86_64", reason="no arm64 legacy boot input images are available")
580-
@pytest.mark.skip(reason="Azure Linux 4.0 ISO bootstrap output is not yet supported")
581580
def test_min_change_legacy_azl4_iso_output(
582581
docker_client: DockerClient,
583582
image_customizer_container_url: str,
@@ -591,6 +590,7 @@ def test_min_change_legacy_azl4_iso_output(
591590
) -> None:
592591
azl_release = 4
593592
config_path = TEST_CONFIGS_DIR.joinpath("iso-bootstrap-vm-azl4.yaml")
593+
config_path = add_preview_features_to_config(config_path, "preview-distro-version", close_list)
594594
output_format = "iso"
595595

596596
run_min_change_test(

toolkit/tools/pkg/imagecustomizerlib/blsutils.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,120 @@ func updateBLSEntryOptions(content string, argsToRemove []string, newArgs []stri
263263

264264
return result
265265
}
266+
267+
// forEachBLSEntry rewrites every non-rescue BLS .conf file under {bootDir}/loader/entries by passing its content
268+
// through edit.
269+
func forEachBLSEntry(bootDir string, edit func(content string) (string, error)) error {
270+
entriesDir := filepath.Join(bootDir, "loader", "entries")
271+
entries, err := os.ReadDir(entriesDir)
272+
if err != nil {
273+
return fmt.Errorf("failed to read BLS entries directory (%s):\n%w", entriesDir, err)
274+
}
275+
276+
for _, entry := range entries {
277+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
278+
continue
279+
}
280+
281+
absPath := filepath.Join(entriesDir, entry.Name())
282+
contentBytes, err := os.ReadFile(absPath)
283+
if err != nil {
284+
return fmt.Errorf("failed to read BLS entry file (%s):\n%w", absPath, err)
285+
}
286+
content := string(contentBytes)
287+
288+
title := ""
289+
for _, field := range bls.ParseFields(content) {
290+
if field.Key == "title" {
291+
title = field.Value
292+
break
293+
}
294+
}
295+
if bls.IsRescueEntryTitle(title) {
296+
continue
297+
}
298+
299+
updatedContent, err := edit(content)
300+
if err != nil {
301+
return fmt.Errorf("failed to update BLS entry file (%s):\n%w", absPath, err)
302+
}
303+
304+
if updatedContent == content {
305+
continue
306+
}
307+
308+
err = os.WriteFile(absPath, []byte(updatedContent), 0o644)
309+
if err != nil {
310+
return fmt.Errorf("failed to write BLS entry file (%s):\n%w", absPath, err)
311+
}
312+
}
313+
314+
return nil
315+
}
316+
317+
// setBLSEntryField replaces the value of the first line with the given key (e.g. "linux" or "initrd") in a BLS entry,
318+
// preserving every other byte. It returns an error if the key is absent unlike updateBLSEntryOptions above, which adds
319+
// an options line if none exists.
320+
func setBLSEntryField(content string, key string, newValue string) (string, error) {
321+
for _, line := range bls.ParseLines(content) {
322+
if line.Key != key {
323+
continue
324+
}
325+
return content[:line.ContentStart] + key + " " + newValue + content[line.ContentEnd:], nil
326+
}
327+
return "", fmt.Errorf("failed to find the '%s' key in BLS entry", key)
328+
}
329+
330+
// updateLiveOSBLSEntries applies the LiveOS-compatibility kernel-entry edits to the BLS entries under bootDir. It is
331+
// the BLS counterpart of the per-entry portion of updateGrubCfgForLiveOS.
332+
func updateLiveOSBLSEntries(bootDir string, initramfsImageType imagecustomizerapi.InitramfsImageType,
333+
disableSELinux bool, savedConfigs *SavedConfigs,
334+
) error {
335+
var argsToRemove []string
336+
var argsToAppend []string
337+
338+
switch initramfsImageType {
339+
case imagecustomizerapi.InitramfsImageTypeFullOS:
340+
// Remove 'root' so that no pivoting takes place.
341+
argsToRemove = append(argsToRemove, "root")
342+
case imagecustomizerapi.InitramfsImageTypeBootstrap:
343+
liveosKernelArgs := fmt.Sprintf(kernelArgsLiveOSTemplate, liveOSDir, liveOSImage)
344+
argsToAppend = append(argsToAppend, strings.Fields(liveosKernelArgs)...)
345+
default:
346+
return fmt.Errorf("unsupported initramfs image type (%s)", initramfsImageType)
347+
}
348+
349+
if disableSELinux {
350+
argsToRemove = append(argsToRemove, selinuxArgNames...)
351+
selinuxArgs, err := selinuxModeToArgs(imagecustomizerapi.SELinuxModeDisabled)
352+
if err != nil {
353+
return err
354+
}
355+
argsToAppend = append(argsToAppend, selinuxArgs...)
356+
}
357+
358+
argsToAppend = append(argsToAppend, savedConfigs.LiveOS.KernelCommandLine.ExtraCommandLine...)
359+
360+
return forEachBLSEntry(bootDir, func(content string) (string, error) {
361+
if initramfsImageType == imagecustomizerapi.InitramfsImageTypeFullOS {
362+
var err error
363+
content, err = setBLSEntryField(content, "initrd", isoInitrdPath)
364+
if err != nil {
365+
return "", fmt.Errorf("failed to update initrd path in BLS entry:\n%w", err)
366+
}
367+
}
368+
content = updateBLSEntryOptions(content, argsToRemove, argsToAppend)
369+
return content, nil
370+
})
371+
}
372+
373+
// setLiveOSBLSEntriesRoot replaces the root= kernel arg in every BLS entry under bootDir and optionally appends
374+
// additional args.
375+
func setLiveOSBLSEntriesRoot(bootDir string, rootValue string, extraArgs []string) error {
376+
argsToRemove := []string{"root"}
377+
newArgs := append([]string{"root=" + rootValue}, extraArgs...)
378+
379+
return forEachBLSEntry(bootDir, func(content string) (string, error) {
380+
return updateBLSEntryOptions(content, argsToRemove, newArgs), nil
381+
})
382+
}

toolkit/tools/pkg/imagecustomizerlib/blsutils_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
"testing"
1010

11+
"github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi"
1112
"github.com/stretchr/testify/assert"
1213
)
1314

@@ -570,3 +571,110 @@ func TestParseBLSOptionsValue(t *testing.T) {
570571
})
571572
}
572573
}
574+
575+
// writeTestBLSEntry creates a single AZL4-style BLS entry under {bootDir}/loader/entries and returns the entry's
576+
// absolute path.
577+
func writeTestBLSEntry(t *testing.T, bootDir string) string {
578+
entriesDir := filepath.Join(bootDir, "loader", "entries")
579+
err := os.MkdirAll(entriesDir, 0o755)
580+
assert.NoError(t, err)
581+
582+
content := "title Azure Linux (6.18.31-1.5.azl4.x86_64) 4.0\n" +
583+
"version 6.18.31-1.5.azl4.x86_64\n" +
584+
"linux /boot/vmlinuz-6.18.31-1.5.azl4.x86_64\n" +
585+
"initrd /boot/initramfs-6.18.31-1.5.azl4.x86_64.img\n" +
586+
"options console=ttyS0 root=UUID=1396c02f-6cf5-438c-9c9c-fb2001079bb9 rd.shell=0\n" +
587+
"grub_users $grub_users\n" +
588+
"grub_arg --unrestricted\n" +
589+
"grub_class azurelinux\n"
590+
591+
entryPath := filepath.Join(entriesDir, "azl4.conf")
592+
err = os.WriteFile(entryPath, []byte(content), 0o644)
593+
assert.NoError(t, err)
594+
return entryPath
595+
}
596+
597+
func TestUpdateLiveOSBLSEntriesFullOS(t *testing.T) {
598+
bootDir := t.TempDir()
599+
entryPath := writeTestBLSEntry(t, bootDir)
600+
601+
savedConfigs := &SavedConfigs{}
602+
savedConfigs.LiveOS.KernelCommandLine.ExtraCommandLine = []string{"rd.info"}
603+
604+
err := updateLiveOSBLSEntries(bootDir, imagecustomizerapi.InitramfsImageTypeFullOS, false /*disableSELinux*/, savedConfigs)
605+
assert.NoError(t, err)
606+
607+
got, err := os.ReadFile(entryPath)
608+
assert.NoError(t, err)
609+
gotStr := string(got)
610+
611+
// Full-OS repoints initrd at the single regenerated /boot/initrd.img.
612+
assert.Regexp(t, `(?m)^initrd /boot/initrd\.img$`, gotStr)
613+
// root= is dropped so no pivot takes place, and the unrelated arg is preserved.
614+
assert.NotContains(t, gotStr, "root=UUID=")
615+
assert.Regexp(t, `(?m)^options .*console=ttyS0`, gotStr)
616+
// The saved extra command line is appended.
617+
assert.Regexp(t, `(?m)^options .* rd\.info$`, gotStr)
618+
// blscfg-only keys are untouched.
619+
assert.Contains(t, gotStr, "grub_class azurelinux")
620+
}
621+
622+
func TestUpdateLiveOSBLSEntriesBootstrap(t *testing.T) {
623+
bootDir := t.TempDir()
624+
entryPath := writeTestBLSEntry(t, bootDir)
625+
626+
savedConfigs := &SavedConfigs{}
627+
savedConfigs.LiveOS.KernelCommandLine.ExtraCommandLine = []string{"rd.shell"}
628+
629+
err := updateLiveOSBLSEntries(bootDir, imagecustomizerapi.InitramfsImageTypeBootstrap, true /*disableSELinux*/, savedConfigs)
630+
assert.NoError(t, err)
631+
632+
got, err := os.ReadFile(entryPath)
633+
assert.NoError(t, err)
634+
gotStr := string(got)
635+
636+
// Bootstrap keeps the per-kernel initrd and the original root (the iso/pxe root is set later).
637+
assert.Regexp(t, `(?m)^initrd /boot/initramfs-6\.18\.31-1\.5\.azl4\.x86_64\.img$`, gotStr)
638+
assert.Contains(t, gotStr, "root=UUID=1396c02f-6cf5-438c-9c9c-fb2001079bb9")
639+
// The dracut live-OS args are appended.
640+
assert.Contains(t, gotStr, "rd.live.image")
641+
assert.Contains(t, gotStr, "rd.live.dir="+liveOSDir)
642+
assert.Contains(t, gotStr, "rd.live.squashimg="+liveOSImage)
643+
// SELinux is disabled.
644+
assert.Contains(t, gotStr, "selinux=0")
645+
// The saved extra command line is appended.
646+
assert.Regexp(t, `(?m)^options .* rd\.shell$`, gotStr)
647+
}
648+
649+
func TestSetLiveOSBLSEntriesRootForIso(t *testing.T) {
650+
bootDir := t.TempDir()
651+
entryPath := writeTestBLSEntry(t, bootDir)
652+
653+
err := setLiveOSBLSEntriesRoot(bootDir, "live:LABEL=foo", nil)
654+
assert.NoError(t, err)
655+
656+
got, err := os.ReadFile(entryPath)
657+
assert.NoError(t, err)
658+
gotStr := string(got)
659+
660+
assert.Contains(t, gotStr, "root=live:LABEL=foo")
661+
assert.NotContains(t, gotStr, "root=UUID=")
662+
}
663+
664+
func TestSetBLSEntryField(t *testing.T) {
665+
content := "title Foo\n" +
666+
"linux /boot/vmlinuz-6.6\n" +
667+
"initrd /boot/initramfs-6.6.img\n" +
668+
"options root=/dev/sda1\n"
669+
670+
got, err := setBLSEntryField(content, "initrd", "/boot/initrd.img")
671+
assert.NoError(t, err)
672+
assert.Regexp(t, `(?m)^initrd /boot/initrd\.img$`, got)
673+
// Other lines are preserved verbatim.
674+
assert.Contains(t, got, "linux /boot/vmlinuz-6.6\n")
675+
assert.Contains(t, got, "options root=/dev/sda1\n")
676+
677+
// Absent key is an error rather than a silent no-op, mirroring the inline-grub path.
678+
_, err = setBLSEntryField(content, "efi", "/x")
679+
assert.Error(t, err)
680+
}

toolkit/tools/pkg/imagecustomizerlib/distrohandler.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,33 @@ type DistroHandler interface {
131131
UpdateBootConfigForVerity(verityMetadata []verityDeviceMetadata, bootPartitionTmpDir string,
132132
bootRelativePath string, partitions []diskutils.PartitionInfo, buildDir string, bootUuid string) error
133133

134+
// UpdateLiveOSGrubCfgForLiveOS applies the common LiveOS-compatibility edits to the grub.cfg generation. It is the
135+
// base that the iso and pxe steps build on.
136+
UpdateLiveOSGrubCfgForLiveOS(grubCfgContent string, bootDir string,
137+
initramfsType imagecustomizerapi.InitramfsImageType, disableSELinux bool, savedConfigs *SavedConfigs,
138+
kernelVersions []string) (string, error)
139+
140+
// UpdateLiveOSGrubCfgForIso applies the iso-specific edits on top of the LiveOS edits.
141+
UpdateLiveOSGrubCfgForIso(grubCfgContent string, bootDir string,
142+
initramfsType imagecustomizerapi.InitramfsImageType) (string, error)
143+
134144
// ShimPackage returns the package that provides the shim EFI binary for this distro on the current architecture.
135145
ShimPackage() string
136146

137147
// GrubEfiPackage returns the package that provides the grub EFI binary for this distro on the current architecture.
138148
GrubEfiPackage() string
139149

150+
// LiveOSRequiredPackages returns the packages that must already be installed in the target image for Image
151+
// Customizer to build a LiveOS bootstrap initrd (the squashfs and dracut live tooling).
152+
LiveOSRequiredPackages() []string
153+
154+
// LiveOSGrubEfiPrefixDir returns the ISO-relative directory that this distro's grub EFI binary uses as its baked-in
155+
// 'prefix', or "" if it doesn't use a baked-in prefix.
156+
LiveOSGrubEfiPrefixDir() string
157+
158+
// LiveOSInitrdDracutModules returns the dracut modules to add when building the LiveOS bootstrap initrd.
159+
LiveOSInitrdDracutModules() []string
160+
140161
// Distro has a root partition that is missing placeholder directories for special mounts like /dev.
141162
RootMissingMountDirectories() bool
142163

toolkit/tools/pkg/imagecustomizerlib/distrohandler_acl.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,19 @@ func (d *aclDistroHandler) UpdateBootConfigForVerity(verityMetadata []verityDevi
295295
return fs.ErrNotExist
296296
}
297297

298+
func (d *aclDistroHandler) UpdateLiveOSGrubCfgForLiveOS(grubCfgContent string, bootDir string,
299+
initramfsType imagecustomizerapi.InitramfsImageType, disableSELinux bool, savedConfigs *SavedConfigs,
300+
kernelVersions []string,
301+
) (string, error) {
302+
return updateGrubCfgForLiveOS(grubCfgContent, initramfsType, disableSELinux, savedConfigs, kernelVersions)
303+
}
304+
305+
func (d *aclDistroHandler) UpdateLiveOSGrubCfgForIso(grubCfgContent string, bootDir string,
306+
initramfsType imagecustomizerapi.InitramfsImageType,
307+
) (string, error) {
308+
return updateGrubCfgForIso(grubCfgContent, initramfsType)
309+
}
310+
298311
func (d *aclDistroHandler) ShimPackage() string {
299312
// ACL uses systemd-boot + UKI (no shim/grub from a package).
300313
return ""
@@ -305,6 +318,18 @@ func (d *aclDistroHandler) GrubEfiPackage() string {
305318
return ""
306319
}
307320

321+
func (d *aclDistroHandler) LiveOSRequiredPackages() []string {
322+
return liveOSRequiredPackagesAzl3
323+
}
324+
325+
func (d *aclDistroHandler) LiveOSGrubEfiPrefixDir() string {
326+
return ""
327+
}
328+
329+
func (d *aclDistroHandler) LiveOSInitrdDracutModules() []string {
330+
return liveOSInitrdDracutModulesAzl3
331+
}
332+
308333
func (d *aclDistroHandler) RootMissingMountDirectories() bool {
309334
return true
310335
}

0 commit comments

Comments
 (0)