Skip to content

Commit 6a2599d

Browse files
authored
Add Phase 0 support: mount, recognize, and validate container images (#708)
<!-- Description: Please provide a summary of the changes and the motivation behind them. --> Enables Image Customizer to mount and recognize container images without modifying them, laying the foundation for full support in subsequent phases. This adds distro detection via VARIANT_ID=azurecontainerlinux in os-release (with /usr/lib/os-release fallback per the os-release(5) spec), a new aclDistroHandler with specific ESP path (boot instead of boot/efi) and systemd-boot bootloader detection, and IC-specific fstab discovery at /usr/share/ic/etc/fstab for USR partition. All hardcoded EspDir usages are replaced with distroHandler.GetEspDir() via a new method on the DistroHandler interface. UKI file discovery is broadened from vmlinuz-*.efi to *.efi to support acl.efi naming, and clearBtrfsReadOnlyProperties is added to handle btrfs subvolumes sealed with ro=true at build time. Test plan Verified go vet and go test pass for all affected packages. Ran end-to-end no-op image customization (mount, detect as UKI boot type, output VHD) successfully. Need to verify no regression on Azure Linux 3.0 images. --- ### **Checklist** - [x] Tests added/updated - [x] Documentation updated (if needed) - [x] Code conforms to style guidelines
1 parent 048be0d commit 6a2599d

16 files changed

Lines changed: 342 additions & 63 deletions

toolkit/tools/internal/osinfo/osinfo.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package osinfo
22

33
import (
4+
"errors"
5+
"io/fs"
46
"os"
57
"path/filepath"
68
"strings"
@@ -10,7 +12,14 @@ import (
1012
func GetDistroAndVersion(rootDir string) (string, string) {
1113
output, err := os.ReadFile(filepath.Join(rootDir, "etc/os-release"))
1214
if err != nil {
13-
return "Unknown Distro", "Unknown Version"
15+
if !errors.Is(err, fs.ErrNotExist) {
16+
return "Unknown Distro", "Unknown Version"
17+
}
18+
// Fall back to /usr/lib/os-release per the os-release(5) spec.
19+
output, err = os.ReadFile(filepath.Join(rootDir, "usr/lib/os-release"))
20+
if err != nil {
21+
return "Unknown Distro", "Unknown Version"
22+
}
1423
}
1524

1625
lines := strings.Split(string(output), "\n")

toolkit/tools/internal/targetos/targetos.go

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,37 @@
44
package targetos
55

66
import (
7+
"errors"
78
"fmt"
9+
"io/fs"
810
"path/filepath"
11+
"strings"
912

1013
"github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/envfile"
1114
)
1215

1316
type TargetOs string
1417

1518
const (
16-
TargetOsAzureLinux2 TargetOs = "azl2"
17-
TargetOsAzureLinux3 TargetOs = "azl3"
18-
TargetOsFedora42 TargetOs = "fedora42"
19-
TargetOsUbuntu2204 TargetOs = "ubuntu2204"
20-
TargetOsUbuntu2404 TargetOs = "ubuntu2404"
19+
TargetOsAzureLinux2 TargetOs = "azl2"
20+
TargetOsAzureLinux3 TargetOs = "azl3"
21+
TargetOsAzureContainerLinux3 TargetOs = "acl3"
22+
TargetOsFedora42 TargetOs = "fedora42"
23+
TargetOsUbuntu2204 TargetOs = "ubuntu2204"
24+
TargetOsUbuntu2404 TargetOs = "ubuntu2404"
2125
)
2226

2327
func GetInstalledTargetOs(rootfs string) (TargetOs, error) {
28+
// Try /etc/os-release first, then fall back to /usr/lib/os-release.
2429
fields, err := envfile.ParseEnvFile(filepath.Join(rootfs, "etc/os-release"))
2530
if err != nil {
26-
return "", fmt.Errorf("failed to read /etc/os-release file:\n%w", err)
31+
if !errors.Is(err, fs.ErrNotExist) {
32+
return "", fmt.Errorf("failed to read /etc/os-release:\n%w", err)
33+
}
34+
fields, err = envfile.ParseEnvFile(filepath.Join(rootfs, "usr/lib/os-release"))
35+
if err != nil {
36+
return "", fmt.Errorf("failed to read os-release (tried /etc/os-release and /usr/lib/os-release):\n%w", err)
37+
}
2738
}
2839

2940
distroId := fields["ID"]
@@ -36,16 +47,30 @@ func GetInstalledTargetOs(rootfs string) (TargetOs, error) {
3647
return TargetOsAzureLinux2, nil
3748

3849
default:
39-
return "", fmt.Errorf("unknown VERSION_ID (%s) for CBL-Mariner in /etc/os-release", versionId)
50+
return "", fmt.Errorf("unknown VERSION_ID (%s) for CBL-Mariner in os-release", versionId)
4051
}
4152

4253
case "azurelinux":
43-
switch versionId {
44-
case "3.0":
45-
return TargetOsAzureLinux3, nil
54+
variantId := fields["VARIANT_ID"]
55+
56+
switch variantId {
57+
case "azurecontainerlinux":
58+
// ACL currently sets VERSION_ID to the full version string (e.g.
59+
// "3.0.20260421") Accept any version that starts with "3."
60+
if !strings.HasPrefix(versionId, "3.") {
61+
return "", fmt.Errorf("unknown VERSION_ID (%s) for Azure Container Linux in os-release", versionId)
62+
}
63+
return TargetOsAzureContainerLinux3, nil
4664

4765
default:
48-
return "", fmt.Errorf("unknown VERSION_ID (%s) for Azure Linux in /etc/os-release", versionId)
66+
// Standard Azure Linux (or unknown variant — treat as standard).
67+
switch versionId {
68+
case "3.0":
69+
return TargetOsAzureLinux3, nil
70+
71+
default:
72+
return "", fmt.Errorf("unknown VERSION_ID (%s) for Azure Linux in os-release", versionId)
73+
}
4974
}
5075

5176
case "fedora":
@@ -54,7 +79,7 @@ func GetInstalledTargetOs(rootfs string) (TargetOs, error) {
5479
return TargetOsFedora42, nil
5580

5681
default:
57-
return "", fmt.Errorf("unknown VERSION_ID (%s) for Fedora in /etc/os-release", versionId)
82+
return "", fmt.Errorf("unknown VERSION_ID (%s) for Fedora in os-release", versionId)
5883
}
5984

6085
case "ubuntu":
@@ -66,10 +91,10 @@ func GetInstalledTargetOs(rootfs string) (TargetOs, error) {
6691
return TargetOsUbuntu2404, nil
6792

6893
default:
69-
return "", fmt.Errorf("unknown VERSION_ID (%s) for Ubuntu in /etc/os-release", versionId)
94+
return "", fmt.Errorf("unknown VERSION_ID (%s) for Ubuntu in os-release", versionId)
7095
}
7196

7297
default:
73-
return "", fmt.Errorf("unknown ID (%s) in /etc/os-release", distroId)
98+
return "", fmt.Errorf("unknown ID (%s) in os-release", distroId)
7499
}
75100
}

toolkit/tools/pkg/imagecustomizerlib/bootcustomizer.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func NewBootCustomizer(imageChroot safechroot.ChrootInterface, uki *imagecustomi
6868
}
6969

7070
// Determine boot configuration type
71-
bootConfigType, err := determineBootConfigType(grubCfgContent, imageChroot)
71+
bootConfigType, err := determineBootConfigType(grubCfgContent, imageChroot, distroHandler)
7272
if err != nil {
7373
return nil, err
7474
}
@@ -93,16 +93,16 @@ func NewBootCustomizer(imageChroot safechroot.ChrootInterface, uki *imagecustomi
9393
return b, nil
9494
}
9595

96-
func determineBootConfigType(grubCfgContent string, imageChroot safechroot.ChrootInterface) (bootConfigType, error) {
97-
// If grub.cfg doesn't exist, check for UKI
96+
func determineBootConfigType(grubCfgContent string, imageChroot safechroot.ChrootInterface, distroHandler DistroHandler) (bootConfigType, error) {
97+
// If grub.cfg doesn't exist, check for UKI using the distro-specific ESP path.
9898
if grubCfgContent == "" {
99-
hasUkis, err := baseImageHasUkis(imageChroot.(*safechroot.Chroot))
100-
if err == nil && hasUkis {
99+
espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir())
100+
ukiFiles, err := getUkiFiles(espDir)
101+
if err == nil && len(ukiFiles) > 0 {
101102
// UKI images without grub.cfg are in passthrough mode (grub.cfg not regenerated)
102103
// For UKI create mode, grub.cfg is regenerated during kernel extraction, so it would exist
103104
return bootConfigTypeUki, nil
104105
}
105-
// No grub.cfg and no UKIs - this is an error
106106
return "", ErrBootNoConfigFound
107107
}
108108

@@ -183,7 +183,7 @@ func (b *BootCustomizer) getSELinuxModeFromCmdline(buildDir string, imageChroot
183183
}
184184

185185
case bootConfigTypeUki:
186-
espDir := filepath.Join(imageChroot.RootDir(), EspDir)
186+
espDir := filepath.Join(imageChroot.RootDir(), b.distroHandler.GetEspDir())
187187

188188
kernelToArgs, err := extractKernelCmdlineFromUkiEfis(espDir, buildDir)
189189
if err != nil {

toolkit/tools/pkg/imagecustomizerlib/cosicommon.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ func extractCosiBootMetadata(buildDirAbs string, imageConnection *imageconnectio
395395
}
396396

397397
// If no config entries, try extracting standalone UKI .efi entries
398-
ukiEntries, err := extractUkiEntriesIfPresent(chrootDir, buildDirAbs)
398+
ukiEntries, err := extractUkiEntriesIfPresent(chrootDir, buildDirAbs, distroHandler)
399399
if err != nil {
400400
return nil, fmt.Errorf("error extracting UKI standalone entries:\n%w", err)
401401
}
@@ -419,8 +419,8 @@ func extractCosiBootMetadata(buildDirAbs string, imageConnection *imageconnectio
419419
}
420420
}
421421

422-
func extractUkiEntriesIfPresent(chrootDir, buildDir string) ([]SystemDBootEntry, error) {
423-
espDir := filepath.Join(chrootDir, EspDir)
422+
func extractUkiEntriesIfPresent(chrootDir, buildDir string, distroHandler DistroHandler) ([]SystemDBootEntry, error) {
423+
espDir := filepath.Join(chrootDir, distroHandler.GetEspDir())
424424

425425
cmdlines, err := extractKernelCmdlineFromUkiEfis(espDir, buildDir)
426426
if err != nil {
@@ -429,7 +429,7 @@ func extractUkiEntriesIfPresent(chrootDir, buildDir string) ([]SystemDBootEntry,
429429

430430
var entries []SystemDBootEntry
431431
for kernelName, cmdline := range cmdlines {
432-
efiPath := filepath.Join("/boot/efi/EFI/Linux", fmt.Sprintf("%s.efi", kernelName))
432+
efiPath := filepath.Join("/", distroHandler.GetEspDir(), "EFI/Linux", fmt.Sprintf("%s.efi", kernelName))
433433
kernelVersion, err := getKernelVersion(kernelName)
434434
if err != nil {
435435
return nil, fmt.Errorf("invalid kernel name in UKI file (%s):\n%w", kernelName, err)

toolkit/tools/pkg/imagecustomizerlib/customizeos.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@ func doOsCustomizations(ctx context.Context, rc *ResolvedConfig, imageConnection
4343
// mode, we skip extraction to preserve existing UKIs.
4444
if rc.Uki != nil && rc.Uki.Mode == imagecustomizerapi.UkiModeCreate {
4545
// Check if base image has UKIs to determine if extraction is needed
46-
hasUkis, err := baseImageHasUkis(imageChroot)
46+
hasUkis, err := baseImageHasUkis(imageChroot, distroHandler)
4747
if err != nil {
4848
return err
4949
}
5050

5151
if hasUkis {
5252
// Base image has UKIs and mode is create - extract for re-customization
53-
err = extractKernelAndInitramfsFromUkis(ctx, imageChroot, rc.BuildDirAbs)
53+
err = extractKernelAndInitramfsFromUkis(ctx, imageChroot, rc.BuildDirAbs, distroHandler)
5454
if err != nil {
5555
return err
5656
}
@@ -65,7 +65,7 @@ func doOsCustomizations(ctx context.Context, rc *ResolvedConfig, imageConnection
6565
return fmt.Errorf("failed to create UKI build directory:\n%w", err)
6666
}
6767

68-
err = extractAndSaveUkiCmdline(rc.BuildDirAbs, imageChroot)
68+
err = extractAndSaveUkiCmdline(rc.BuildDirAbs, imageChroot, distroHandler)
6969
if err != nil {
7070
return fmt.Errorf("failed to extract UKI cmdline for modify mode:\n%w", err)
7171
}

toolkit/tools/pkg/imagecustomizerlib/customizeuki.go

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ type UkiKernelInfo struct {
6363
Initramfs string `json:"initramfs,omitempty"` // Optional: empty in modify mode
6464
}
6565

66-
func baseImageHasUkis(imageChroot *safechroot.Chroot) (bool, error) {
67-
espDir := filepath.Join(imageChroot.RootDir(), EspDir)
66+
func baseImageHasUkis(imageChroot *safechroot.Chroot, distroHandler DistroHandler) (bool, error) {
67+
espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir())
6868
ukiFiles, err := getUkiFiles(espDir)
6969
if err != nil {
7070
return false, fmt.Errorf("failed to check for UKI files:\n%w", err)
@@ -112,8 +112,8 @@ func baseImageHasUkiAddons(espPath string) (bool, error) {
112112
// - mode: create: Extract and regenerate UKIs
113113
// - mode: passthrough: Preserve existing UKIs without modification
114114
// - mode: modify: Check for addon, modify addon only (preserve main UKI)
115-
func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imagecustomizerapi.Uki) error {
116-
hasUkis, err := baseImageHasUkis(imageConnection.Chroot())
115+
func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imagecustomizerapi.Uki, distroHandler DistroHandler) error {
116+
hasUkis, err := baseImageHasUkis(imageConnection.Chroot(), distroHandler)
117117
if err != nil {
118118
return err
119119
}
@@ -157,7 +157,7 @@ func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imag
157157

158158
// For modify mode, validate that base image has UKI addons
159159
if uki.Mode == imagecustomizerapi.UkiModeModify {
160-
espDir := filepath.Join(imageConnection.Chroot().RootDir(), EspDir)
160+
espDir := filepath.Join(imageConnection.Chroot().RootDir(), distroHandler.GetEspDir())
161161
hasAddons, err := baseImageHasUkiAddons(espDir)
162162
if err != nil {
163163
return fmt.Errorf("failed to check for UKI addons:\n%w", err)
@@ -173,8 +173,8 @@ func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imag
173173
}
174174

175175
// extractAndSaveUkiCmdline extracts the kernel cmdline from existing UKI addons and saves them to uki-kernel-info.json.
176-
func extractAndSaveUkiCmdline(buildDir string, imageChroot *safechroot.Chroot) error {
177-
espDir := filepath.Join(imageChroot.RootDir(), EspDir)
176+
func extractAndSaveUkiCmdline(buildDir string, imageChroot *safechroot.Chroot, distroHandler DistroHandler) error {
177+
espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir())
178178
ukiFiles, err := getUkiFiles(espDir)
179179
if err != nil {
180180
return fmt.Errorf("failed to get UKI files:\n%w", err)
@@ -307,13 +307,13 @@ func prepareUkiHelper(ctx context.Context, buildDir string, uki *imagecustomizer
307307
}
308308

309309
// Extract kernel command line arguments from either grub.cfg or UKI.
310-
espDir := filepath.Join(imageChroot.RootDir(), EspDir)
310+
espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir())
311311
kernelToArgs, err := extractKernelToArgs(espDir, bootDir, buildDir)
312312
if err != nil {
313313
return fmt.Errorf("%w:\n%w", ErrUKIKernelCmdlineExtract, err)
314314
}
315315

316-
err = cleanBootDirectory(imageChroot)
316+
err = cleanBootDirectory(imageChroot, distroHandler)
317317
if err != nil {
318318
return fmt.Errorf("%w:\n%w", ErrUKICleanBootDir, err)
319319
}
@@ -965,13 +965,17 @@ func getKernelNameFromUki(ukiPath string) (string, error) {
965965
fileName := filepath.Base(ukiPath)
966966

967967
matches := ukiNamePattern.FindStringSubmatch(fileName)
968-
if len(matches) != 2 {
969-
return "", fmt.Errorf("invalid UKI file name: (%s)", fileName)
968+
if len(matches) == 2 {
969+
// Standard UKI naming: vmlinuz-<version>.efi → vmlinuz-<version>
970+
return "vmlinuz-" + matches[1], nil
970971
}
971972

972-
// Reconstruct kernel name (vmlinuz-<version>, e.g., vmlinuz-6.6.51.1-5.azl3)
973-
kernelName := "vmlinuz-" + matches[1]
974-
return kernelName, nil
973+
// Non-standard UKI naming (e.g., acl.efi): use filename without .efi extension
974+
if strings.HasSuffix(fileName, ".efi") {
975+
return strings.TrimSuffix(fileName, ".efi"), nil
976+
}
977+
978+
return "", fmt.Errorf("invalid UKI file name: (%s)", fileName)
975979
}
976980

977981
func extractSectionFromUkiWithObjcopy(ukiPath string, sectionName string, outputPath string, buildDir string) error {
@@ -998,22 +1002,22 @@ func extractSectionFromUkiWithObjcopy(ukiPath string, sectionName string, output
9981002
return nil
9991003
}
10001004

1001-
func extractKernelAndInitramfsFromUkis(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string) error {
1002-
err := extractKernelAndInitramfsFromUkisHelper(ctx, imageChroot, buildDir)
1005+
func extractKernelAndInitramfsFromUkis(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string, distroHandler DistroHandler) error {
1006+
err := extractKernelAndInitramfsFromUkisHelper(ctx, imageChroot, buildDir, distroHandler)
10031007
if err != nil {
10041008
return fmt.Errorf("%w:\n%w", ErrUKIExtractComponents, err)
10051009
}
10061010

10071011
return nil
10081012
}
10091013

1010-
func extractKernelAndInitramfsFromUkisHelper(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string) error {
1014+
func extractKernelAndInitramfsFromUkisHelper(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string, distroHandler DistroHandler) error {
10111015
logger.Log.Infof("Extracting kernel and initramfs from existing UKIs for re-customization")
10121016

10131017
_, span := otel.GetTracerProvider().Tracer(OtelTracerName).Start(ctx, "extract_kernel_initramfs_from_ukis")
10141018
defer span.End()
10151019

1016-
espDir := filepath.Join(imageChroot.RootDir(), EspDir)
1020+
espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir())
10171021
ukiFiles, err := getUkiFiles(espDir)
10181022
if err != nil {
10191023
return err
@@ -1121,9 +1125,9 @@ func cleanUkiDirectory(ukiOutputDir string) error {
11211125
return nil
11221126
}
11231127

1124-
func cleanBootDirectory(imageChroot *safechroot.Chroot) error {
1128+
func cleanBootDirectory(imageChroot *safechroot.Chroot, distroHandler DistroHandler) error {
11251129
bootPath := filepath.Join(imageChroot.RootDir(), BootDir)
1126-
espPath := filepath.Join(imageChroot.RootDir(), EspDir)
1130+
espPath := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir())
11271131

11281132
dirEntries, err := os.ReadDir(bootPath)
11291133
if err != nil {

toolkit/tools/pkg/imagecustomizerlib/customizeuki_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,3 +566,45 @@ func getUkiAddonFiles(espPath string) ([]string, error) {
566566

567567
return addonFiles, nil
568568
}
569+
570+
func TestGetKernelNameFromUki(t *testing.T) {
571+
tests := []struct {
572+
name string
573+
ukiPath string
574+
expected string
575+
expectError bool
576+
}{
577+
{
578+
name: "standard vmlinuz naming",
579+
ukiPath: "/boot/efi/EFI/Linux/vmlinuz-6.6.51.1-5.azl3.efi",
580+
expected: "vmlinuz-6.6.51.1-5.azl3",
581+
},
582+
{
583+
name: "non-standard naming (ACL)",
584+
ukiPath: "/boot/EFI/Linux/acl.efi",
585+
expected: "acl",
586+
},
587+
{
588+
name: "non-standard naming with path",
589+
ukiPath: "/some/path/custom-kernel.efi",
590+
expected: "custom-kernel",
591+
},
592+
{
593+
name: "no .efi extension",
594+
ukiPath: "/boot/EFI/Linux/vmlinuz-6.6.51",
595+
expectError: true,
596+
},
597+
}
598+
599+
for _, tt := range tests {
600+
t.Run(tt.name, func(t *testing.T) {
601+
result, err := getKernelNameFromUki(tt.ukiPath)
602+
if tt.expectError {
603+
assert.Error(t, err)
604+
} else {
605+
assert.NoError(t, err)
606+
assert.Equal(t, tt.expected, result)
607+
}
608+
})
609+
}
610+
}

0 commit comments

Comments
 (0)