diff --git a/toolkit/tools/internal/osinfo/osinfo.go b/toolkit/tools/internal/osinfo/osinfo.go index 6fbab5ffb..2c07df842 100644 --- a/toolkit/tools/internal/osinfo/osinfo.go +++ b/toolkit/tools/internal/osinfo/osinfo.go @@ -1,6 +1,8 @@ package osinfo import ( + "errors" + "io/fs" "os" "path/filepath" "strings" @@ -10,7 +12,14 @@ import ( func GetDistroAndVersion(rootDir string) (string, string) { output, err := os.ReadFile(filepath.Join(rootDir, "etc/os-release")) if err != nil { - return "Unknown Distro", "Unknown Version" + if !errors.Is(err, fs.ErrNotExist) { + return "Unknown Distro", "Unknown Version" + } + // Fall back to /usr/lib/os-release per the os-release(5) spec. + output, err = os.ReadFile(filepath.Join(rootDir, "usr/lib/os-release")) + if err != nil { + return "Unknown Distro", "Unknown Version" + } } lines := strings.Split(string(output), "\n") diff --git a/toolkit/tools/internal/targetos/targetos.go b/toolkit/tools/internal/targetos/targetos.go index c77e3cb32..1c4ca824b 100644 --- a/toolkit/tools/internal/targetos/targetos.go +++ b/toolkit/tools/internal/targetos/targetos.go @@ -4,8 +4,11 @@ package targetos import ( + "errors" "fmt" + "io/fs" "path/filepath" + "strings" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/envfile" ) @@ -13,17 +16,25 @@ import ( type TargetOs string const ( - TargetOsAzureLinux2 TargetOs = "azl2" - TargetOsAzureLinux3 TargetOs = "azl3" - TargetOsFedora42 TargetOs = "fedora42" - TargetOsUbuntu2204 TargetOs = "ubuntu2204" - TargetOsUbuntu2404 TargetOs = "ubuntu2404" + TargetOsAzureLinux2 TargetOs = "azl2" + TargetOsAzureLinux3 TargetOs = "azl3" + TargetOsAzureContainerLinux3 TargetOs = "acl3" + TargetOsFedora42 TargetOs = "fedora42" + TargetOsUbuntu2204 TargetOs = "ubuntu2204" + TargetOsUbuntu2404 TargetOs = "ubuntu2404" ) func GetInstalledTargetOs(rootfs string) (TargetOs, error) { + // Try /etc/os-release first, then fall back to /usr/lib/os-release. fields, err := envfile.ParseEnvFile(filepath.Join(rootfs, "etc/os-release")) if err != nil { - return "", fmt.Errorf("failed to read /etc/os-release file:\n%w", err) + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("failed to read /etc/os-release:\n%w", err) + } + fields, err = envfile.ParseEnvFile(filepath.Join(rootfs, "usr/lib/os-release")) + if err != nil { + return "", fmt.Errorf("failed to read os-release (tried /etc/os-release and /usr/lib/os-release):\n%w", err) + } } distroId := fields["ID"] @@ -36,16 +47,30 @@ func GetInstalledTargetOs(rootfs string) (TargetOs, error) { return TargetOsAzureLinux2, nil default: - return "", fmt.Errorf("unknown VERSION_ID (%s) for CBL-Mariner in /etc/os-release", versionId) + return "", fmt.Errorf("unknown VERSION_ID (%s) for CBL-Mariner in os-release", versionId) } case "azurelinux": - switch versionId { - case "3.0": - return TargetOsAzureLinux3, nil + variantId := fields["VARIANT_ID"] + + switch variantId { + case "azurecontainerlinux": + // ACL currently sets VERSION_ID to the full version string (e.g. + // "3.0.20260421") Accept any version that starts with "3." + if !strings.HasPrefix(versionId, "3.") { + return "", fmt.Errorf("unknown VERSION_ID (%s) for Azure Container Linux in os-release", versionId) + } + return TargetOsAzureContainerLinux3, nil default: - return "", fmt.Errorf("unknown VERSION_ID (%s) for Azure Linux in /etc/os-release", versionId) + // Standard Azure Linux (or unknown variant — treat as standard). + switch versionId { + case "3.0": + return TargetOsAzureLinux3, nil + + default: + return "", fmt.Errorf("unknown VERSION_ID (%s) for Azure Linux in os-release", versionId) + } } case "fedora": @@ -54,7 +79,7 @@ func GetInstalledTargetOs(rootfs string) (TargetOs, error) { return TargetOsFedora42, nil default: - return "", fmt.Errorf("unknown VERSION_ID (%s) for Fedora in /etc/os-release", versionId) + return "", fmt.Errorf("unknown VERSION_ID (%s) for Fedora in os-release", versionId) } case "ubuntu": @@ -66,10 +91,10 @@ func GetInstalledTargetOs(rootfs string) (TargetOs, error) { return TargetOsUbuntu2404, nil default: - return "", fmt.Errorf("unknown VERSION_ID (%s) for Ubuntu in /etc/os-release", versionId) + return "", fmt.Errorf("unknown VERSION_ID (%s) for Ubuntu in os-release", versionId) } default: - return "", fmt.Errorf("unknown ID (%s) in /etc/os-release", distroId) + return "", fmt.Errorf("unknown ID (%s) in os-release", distroId) } } diff --git a/toolkit/tools/pkg/imagecustomizerlib/bootcustomizer.go b/toolkit/tools/pkg/imagecustomizerlib/bootcustomizer.go index c4ae11195..ac4a989fc 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/bootcustomizer.go +++ b/toolkit/tools/pkg/imagecustomizerlib/bootcustomizer.go @@ -68,7 +68,7 @@ func NewBootCustomizer(imageChroot safechroot.ChrootInterface, uki *imagecustomi } // Determine boot configuration type - bootConfigType, err := determineBootConfigType(grubCfgContent, imageChroot) + bootConfigType, err := determineBootConfigType(grubCfgContent, imageChroot, distroHandler) if err != nil { return nil, err } @@ -93,16 +93,16 @@ func NewBootCustomizer(imageChroot safechroot.ChrootInterface, uki *imagecustomi return b, nil } -func determineBootConfigType(grubCfgContent string, imageChroot safechroot.ChrootInterface) (bootConfigType, error) { - // If grub.cfg doesn't exist, check for UKI +func determineBootConfigType(grubCfgContent string, imageChroot safechroot.ChrootInterface, distroHandler DistroHandler) (bootConfigType, error) { + // If grub.cfg doesn't exist, check for UKI using the distro-specific ESP path. if grubCfgContent == "" { - hasUkis, err := baseImageHasUkis(imageChroot.(*safechroot.Chroot)) - if err == nil && hasUkis { + espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir()) + ukiFiles, err := getUkiFiles(espDir) + if err == nil && len(ukiFiles) > 0 { // UKI images without grub.cfg are in passthrough mode (grub.cfg not regenerated) // For UKI create mode, grub.cfg is regenerated during kernel extraction, so it would exist return bootConfigTypeUki, nil } - // No grub.cfg and no UKIs - this is an error return "", ErrBootNoConfigFound } @@ -183,7 +183,7 @@ func (b *BootCustomizer) getSELinuxModeFromCmdline(buildDir string, imageChroot } case bootConfigTypeUki: - espDir := filepath.Join(imageChroot.RootDir(), EspDir) + espDir := filepath.Join(imageChroot.RootDir(), b.distroHandler.GetEspDir()) kernelToArgs, err := extractKernelCmdlineFromUkiEfis(espDir, buildDir) if err != nil { diff --git a/toolkit/tools/pkg/imagecustomizerlib/cosicommon.go b/toolkit/tools/pkg/imagecustomizerlib/cosicommon.go index 39834dec9..dab1040c6 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/cosicommon.go +++ b/toolkit/tools/pkg/imagecustomizerlib/cosicommon.go @@ -395,7 +395,7 @@ func extractCosiBootMetadata(buildDirAbs string, imageConnection *imageconnectio } // If no config entries, try extracting standalone UKI .efi entries - ukiEntries, err := extractUkiEntriesIfPresent(chrootDir, buildDirAbs) + ukiEntries, err := extractUkiEntriesIfPresent(chrootDir, buildDirAbs, distroHandler) if err != nil { return nil, fmt.Errorf("error extracting UKI standalone entries:\n%w", err) } @@ -419,8 +419,8 @@ func extractCosiBootMetadata(buildDirAbs string, imageConnection *imageconnectio } } -func extractUkiEntriesIfPresent(chrootDir, buildDir string) ([]SystemDBootEntry, error) { - espDir := filepath.Join(chrootDir, EspDir) +func extractUkiEntriesIfPresent(chrootDir, buildDir string, distroHandler DistroHandler) ([]SystemDBootEntry, error) { + espDir := filepath.Join(chrootDir, distroHandler.GetEspDir()) cmdlines, err := extractKernelCmdlineFromUkiEfis(espDir, buildDir) if err != nil { @@ -429,7 +429,7 @@ func extractUkiEntriesIfPresent(chrootDir, buildDir string) ([]SystemDBootEntry, var entries []SystemDBootEntry for kernelName, cmdline := range cmdlines { - efiPath := filepath.Join("/boot/efi/EFI/Linux", fmt.Sprintf("%s.efi", kernelName)) + efiPath := filepath.Join("/", distroHandler.GetEspDir(), "EFI/Linux", fmt.Sprintf("%s.efi", kernelName)) kernelVersion, err := getKernelVersion(kernelName) if err != nil { return nil, fmt.Errorf("invalid kernel name in UKI file (%s):\n%w", kernelName, err) diff --git a/toolkit/tools/pkg/imagecustomizerlib/customizeos.go b/toolkit/tools/pkg/imagecustomizerlib/customizeos.go index 1c5aad6c1..14dd0ad13 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/customizeos.go +++ b/toolkit/tools/pkg/imagecustomizerlib/customizeos.go @@ -43,14 +43,14 @@ func doOsCustomizations(ctx context.Context, rc *ResolvedConfig, imageConnection // mode, we skip extraction to preserve existing UKIs. if rc.Uki != nil && rc.Uki.Mode == imagecustomizerapi.UkiModeCreate { // Check if base image has UKIs to determine if extraction is needed - hasUkis, err := baseImageHasUkis(imageChroot) + hasUkis, err := baseImageHasUkis(imageChroot, distroHandler) if err != nil { return err } if hasUkis { // Base image has UKIs and mode is create - extract for re-customization - err = extractKernelAndInitramfsFromUkis(ctx, imageChroot, rc.BuildDirAbs) + err = extractKernelAndInitramfsFromUkis(ctx, imageChroot, rc.BuildDirAbs, distroHandler) if err != nil { return err } @@ -65,7 +65,7 @@ func doOsCustomizations(ctx context.Context, rc *ResolvedConfig, imageConnection return fmt.Errorf("failed to create UKI build directory:\n%w", err) } - err = extractAndSaveUkiCmdline(rc.BuildDirAbs, imageChroot) + err = extractAndSaveUkiCmdline(rc.BuildDirAbs, imageChroot, distroHandler) if err != nil { return fmt.Errorf("failed to extract UKI cmdline for modify mode:\n%w", err) } diff --git a/toolkit/tools/pkg/imagecustomizerlib/customizeuki.go b/toolkit/tools/pkg/imagecustomizerlib/customizeuki.go index 5650aff73..d0464f93b 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/customizeuki.go +++ b/toolkit/tools/pkg/imagecustomizerlib/customizeuki.go @@ -63,8 +63,8 @@ type UkiKernelInfo struct { Initramfs string `json:"initramfs,omitempty"` // Optional: empty in modify mode } -func baseImageHasUkis(imageChroot *safechroot.Chroot) (bool, error) { - espDir := filepath.Join(imageChroot.RootDir(), EspDir) +func baseImageHasUkis(imageChroot *safechroot.Chroot, distroHandler DistroHandler) (bool, error) { + espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir()) ukiFiles, err := getUkiFiles(espDir) if err != nil { return false, fmt.Errorf("failed to check for UKI files:\n%w", err) @@ -112,8 +112,8 @@ func baseImageHasUkiAddons(espPath string) (bool, error) { // - mode: create: Extract and regenerate UKIs // - mode: passthrough: Preserve existing UKIs without modification // - mode: modify: Check for addon, modify addon only (preserve main UKI) -func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imagecustomizerapi.Uki) error { - hasUkis, err := baseImageHasUkis(imageConnection.Chroot()) +func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imagecustomizerapi.Uki, distroHandler DistroHandler) error { + hasUkis, err := baseImageHasUkis(imageConnection.Chroot(), distroHandler) if err != nil { return err } @@ -157,7 +157,7 @@ func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imag // For modify mode, validate that base image has UKI addons if uki.Mode == imagecustomizerapi.UkiModeModify { - espDir := filepath.Join(imageConnection.Chroot().RootDir(), EspDir) + espDir := filepath.Join(imageConnection.Chroot().RootDir(), distroHandler.GetEspDir()) hasAddons, err := baseImageHasUkiAddons(espDir) if err != nil { return fmt.Errorf("failed to check for UKI addons:\n%w", err) @@ -173,8 +173,8 @@ func validateUkiMode(imageConnection *imageconnection.ImageConnection, uki *imag } // extractAndSaveUkiCmdline extracts the kernel cmdline from existing UKI addons and saves them to uki-kernel-info.json. -func extractAndSaveUkiCmdline(buildDir string, imageChroot *safechroot.Chroot) error { - espDir := filepath.Join(imageChroot.RootDir(), EspDir) +func extractAndSaveUkiCmdline(buildDir string, imageChroot *safechroot.Chroot, distroHandler DistroHandler) error { + espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir()) ukiFiles, err := getUkiFiles(espDir) if err != nil { return fmt.Errorf("failed to get UKI files:\n%w", err) @@ -307,13 +307,13 @@ func prepareUkiHelper(ctx context.Context, buildDir string, uki *imagecustomizer } // Extract kernel command line arguments from either grub.cfg or UKI. - espDir := filepath.Join(imageChroot.RootDir(), EspDir) + espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir()) kernelToArgs, err := extractKernelToArgs(espDir, bootDir, buildDir) if err != nil { return fmt.Errorf("%w:\n%w", ErrUKIKernelCmdlineExtract, err) } - err = cleanBootDirectory(imageChroot) + err = cleanBootDirectory(imageChroot, distroHandler) if err != nil { return fmt.Errorf("%w:\n%w", ErrUKICleanBootDir, err) } @@ -965,13 +965,17 @@ func getKernelNameFromUki(ukiPath string) (string, error) { fileName := filepath.Base(ukiPath) matches := ukiNamePattern.FindStringSubmatch(fileName) - if len(matches) != 2 { - return "", fmt.Errorf("invalid UKI file name: (%s)", fileName) + if len(matches) == 2 { + // Standard UKI naming: vmlinuz-.efi → vmlinuz- + return "vmlinuz-" + matches[1], nil } - // Reconstruct kernel name (vmlinuz-, e.g., vmlinuz-6.6.51.1-5.azl3) - kernelName := "vmlinuz-" + matches[1] - return kernelName, nil + // Non-standard UKI naming (e.g., acl.efi): use filename without .efi extension + if strings.HasSuffix(fileName, ".efi") { + return strings.TrimSuffix(fileName, ".efi"), nil + } + + return "", fmt.Errorf("invalid UKI file name: (%s)", fileName) } func extractSectionFromUkiWithObjcopy(ukiPath string, sectionName string, outputPath string, buildDir string) error { @@ -998,8 +1002,8 @@ func extractSectionFromUkiWithObjcopy(ukiPath string, sectionName string, output return nil } -func extractKernelAndInitramfsFromUkis(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string) error { - err := extractKernelAndInitramfsFromUkisHelper(ctx, imageChroot, buildDir) +func extractKernelAndInitramfsFromUkis(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string, distroHandler DistroHandler) error { + err := extractKernelAndInitramfsFromUkisHelper(ctx, imageChroot, buildDir, distroHandler) if err != nil { return fmt.Errorf("%w:\n%w", ErrUKIExtractComponents, err) } @@ -1007,13 +1011,13 @@ func extractKernelAndInitramfsFromUkis(ctx context.Context, imageChroot *safechr return nil } -func extractKernelAndInitramfsFromUkisHelper(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string) error { +func extractKernelAndInitramfsFromUkisHelper(ctx context.Context, imageChroot *safechroot.Chroot, buildDir string, distroHandler DistroHandler) error { logger.Log.Infof("Extracting kernel and initramfs from existing UKIs for re-customization") _, span := otel.GetTracerProvider().Tracer(OtelTracerName).Start(ctx, "extract_kernel_initramfs_from_ukis") defer span.End() - espDir := filepath.Join(imageChroot.RootDir(), EspDir) + espDir := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir()) ukiFiles, err := getUkiFiles(espDir) if err != nil { return err @@ -1121,9 +1125,9 @@ func cleanUkiDirectory(ukiOutputDir string) error { return nil } -func cleanBootDirectory(imageChroot *safechroot.Chroot) error { +func cleanBootDirectory(imageChroot *safechroot.Chroot, distroHandler DistroHandler) error { bootPath := filepath.Join(imageChroot.RootDir(), BootDir) - espPath := filepath.Join(imageChroot.RootDir(), EspDir) + espPath := filepath.Join(imageChroot.RootDir(), distroHandler.GetEspDir()) dirEntries, err := os.ReadDir(bootPath) if err != nil { diff --git a/toolkit/tools/pkg/imagecustomizerlib/customizeuki_test.go b/toolkit/tools/pkg/imagecustomizerlib/customizeuki_test.go index 517df19e1..161e487a9 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/customizeuki_test.go +++ b/toolkit/tools/pkg/imagecustomizerlib/customizeuki_test.go @@ -566,3 +566,45 @@ func getUkiAddonFiles(espPath string) ([]string, error) { return addonFiles, nil } + +func TestGetKernelNameFromUki(t *testing.T) { + tests := []struct { + name string + ukiPath string + expected string + expectError bool + }{ + { + name: "standard vmlinuz naming", + ukiPath: "/boot/efi/EFI/Linux/vmlinuz-6.6.51.1-5.azl3.efi", + expected: "vmlinuz-6.6.51.1-5.azl3", + }, + { + name: "non-standard naming (ACL)", + ukiPath: "/boot/EFI/Linux/acl.efi", + expected: "acl", + }, + { + name: "non-standard naming with path", + ukiPath: "/some/path/custom-kernel.efi", + expected: "custom-kernel", + }, + { + name: "no .efi extension", + ukiPath: "/boot/EFI/Linux/vmlinuz-6.6.51", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := getKernelNameFromUki(tt.ukiPath) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/distrohandler.go b/toolkit/tools/pkg/imagecustomizerlib/distrohandler.go index dc475c1fa..c3a5c2105 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/distrohandler.go +++ b/toolkit/tools/pkg/imagecustomizerlib/distrohandler.go @@ -51,6 +51,10 @@ type DistroHandler interface { // Detect the bootloader type installed in the image DetectBootloaderType(imageChroot safechroot.ChrootInterface) (BootloaderType, error) + // GetEspDir returns the ESP directory path relative to the image root. + // For example: "boot/efi" for most distros, "boot" for ACL. + GetEspDir() string + // Reports whether SELinux configuration is supported by the tool for this distro. SELinuxSupported() bool @@ -79,6 +83,8 @@ func NewDistroHandlerFromTargetOs(targetOs targetos.TargetOs) DistroHandler { return newAzureLinuxDistroHandler("2.0") case targetos.TargetOsAzureLinux3: return newAzureLinuxDistroHandler("3.0") + case targetos.TargetOsAzureContainerLinux3: + return newAclDistroHandler() case targetos.TargetOsUbuntu2204: return newUbuntuDistroHandler("22.04") case targetos.TargetOsUbuntu2404: diff --git a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_acl.go b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_acl.go new file mode 100644 index 000000000..369c9a141 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_acl.go @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerlib + +import ( + "context" + "fmt" + "io/fs" + + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi" + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/imageconnection" + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/safechroot" + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/targetos" +) + +// aclDistroHandler implements DistroHandler for Azure Container Linux (ACL). +// ACL uses systemd-boot + UKI (no GRUB) and has an immutable /usr with dm-verity. +type aclDistroHandler struct { + packageManager rpmPackageManagerHandler +} + +func newAclDistroHandler() *aclDistroHandler { + return &aclDistroHandler{ + packageManager: newTdnfPackageManager("3.0"), + } +} + +func (d *aclDistroHandler) GetTargetOs() targetos.TargetOs { + return targetos.TargetOsAzureContainerLinux3 +} + +func (d *aclDistroHandler) ValidateConfig(rc *ResolvedConfig) error { + // ACL Phase 0: only mount/recognize/passthrough is supported. + // Block operations that would fail with confusing errors later. + + if rc.Storage.CustomizePartitions() { + return fmt.Errorf("storage repartitioning is not yet supported for ACL") + } + + if rc.BootLoader.ResetType == imagecustomizerapi.ResetBootLoaderTypeHard { + return fmt.Errorf("bootloader hard-reset is not supported on ACL (ACL uses systemd-boot, not GRUB)") + } + + if rc.Uki != nil && rc.Uki.Mode != imagecustomizerapi.UkiModePassthrough { + return fmt.Errorf("only UKI passthrough mode is currently supported for ACL (got %q)", rc.Uki.Mode) + } + + if len(rc.OsKernelCommandLine.ExtraCommandLine) > 0 { + return fmt.Errorf("kernel command line modification is not yet supported for ACL") + } + + return nil +} + +func (d *aclDistroHandler) ManagePackages(ctx context.Context, buildDir string, baseConfigPath string, + config *imagecustomizerapi.OS, imageChroot *safechroot.Chroot, toolsChroot *safechroot.Chroot, + rpmsSources []string, useBaseImageRpmRepos bool, snapshotTime imagecustomizerapi.PackageSnapshotTime, +) error { + return managePackagesRpm( + ctx, buildDir, baseConfigPath, config, imageChroot, toolsChroot, rpmsSources, useBaseImageRpmRepos, + snapshotTime, d.packageManager) +} + +func (d *aclDistroHandler) IsPackageInstalled(imageChroot safechroot.ChrootInterface, packageName string) bool { + return d.packageManager.isPackageInstalled(imageChroot, packageName) +} + +func (d *aclDistroHandler) GetAllPackagesFromChroot(imageChroot safechroot.ChrootInterface) ([]OsPackage, error) { + return getAllPackagesFromChrootRpm(imageChroot) +} + +func (d *aclDistroHandler) DetectBootloaderType(imageChroot safechroot.ChrootInterface) (BootloaderType, error) { + return BootloaderTypeSystemdBoot, nil +} + +func (d *aclDistroHandler) GetEspDir() string { + return "boot" +} + +func (d *aclDistroHandler) SELinuxSupported() bool { + return true +} + +func (d *aclDistroHandler) ReadGrub2ConfigFile(imageChroot safechroot.ChrootInterface) (string, error) { + // ACL does not use GRUB. Return empty string with ErrNotExist so callers + // that tolerate a missing grub.cfg can proceed without error. + return "", fs.ErrNotExist +} + +func (d *aclDistroHandler) WriteGrub2ConfigFile(grub2Config string, + imageChroot safechroot.ChrootInterface, +) error { + return fmt.Errorf("GRUB is not supported on ACL") +} + +func (d *aclDistroHandler) RegenerateInitramfs(ctx context.Context, imageChroot *safechroot.Chroot) error { + return fmt.Errorf("initramfs regeneration is not yet supported for ACL") +} + +func (d *aclDistroHandler) ConfigureDiskBootLoader(imageConnection *imageconnection.ImageConnection, + rootMountIdType imagecustomizerapi.MountIdentifierType, bootType imagecustomizerapi.BootType, + selinuxConfig imagecustomizerapi.SELinux, kernelCommandLine imagecustomizerapi.KernelCommandLine, + currentSELinuxMode imagecustomizerapi.SELinuxMode, newImage bool, +) error { + return fmt.Errorf("bootloader configuration is not yet supported for ACL") +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_azurelinux.go b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_azurelinux.go index 902ff53a2..32dafe119 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_azurelinux.go +++ b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_azurelinux.go @@ -74,6 +74,10 @@ func (d *azureLinuxDistroHandler) DetectBootloaderType(imageChroot safechroot.Ch return "", fmt.Errorf("unknown bootloader: neither grub2-efi-binary, grub2-efi-binary-noprefix, nor systemd-boot found") } +func (d *azureLinuxDistroHandler) GetEspDir() string { + return "boot/efi" +} + func (d *azureLinuxDistroHandler) SELinuxSupported() bool { return true } @@ -127,5 +131,5 @@ func (d *azureLinuxDistroHandler) ConfigureDiskBootLoader(imageConnection *image forceGrubMkconfig := newImage || d.version != "2.0" return configureDiskBootLoader(imageConnection, rootMountIdType, bootType, selinuxConfig, kernelCommandLine, - currentSELinuxMode, forceGrubMkconfig) + currentSELinuxMode, forceGrubMkconfig, d) } diff --git a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_fedora.go b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_fedora.go index 7ceaf1839..a669440f6 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_fedora.go +++ b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_fedora.go @@ -87,6 +87,10 @@ func (d *fedoraDistroHandler) DetectBootloaderType(imageChroot safechroot.Chroot return "", fmt.Errorf("unknown bootloader: neither grub2-efi-x64, grub2-efi-aa64, nor systemd-boot found") } +func (d *fedoraDistroHandler) GetEspDir() string { + return "boot/efi" +} + func (d *fedoraDistroHandler) SELinuxSupported() bool { return true } @@ -125,5 +129,5 @@ func (d *fedoraDistroHandler) ConfigureDiskBootLoader(imageConnection *imageconn currentSELinuxMode imagecustomizerapi.SELinuxMode, newImage bool, ) error { return configureDiskBootLoader(imageConnection, rootMountIdType, bootType, selinuxConfig, kernelCommandLine, - currentSELinuxMode, true /* forceGrubMkconfig */) + currentSELinuxMode, true /* forceGrubMkconfig */, d) } diff --git a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_ubuntu.go b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_ubuntu.go index ae9db8970..d2aa66565 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/distrohandler_ubuntu.go +++ b/toolkit/tools/pkg/imagecustomizerlib/distrohandler_ubuntu.go @@ -108,6 +108,10 @@ func (d *ubuntuDistroHandler) DetectBootloaderType(imageChroot safechroot.Chroot return "", fmt.Errorf("unknown bootloader: neither grub-efi-amd64, grub-efi-arm64, nor systemd-boot found") } +func (d *ubuntuDistroHandler) GetEspDir() string { + return "boot/efi" +} + func (d *ubuntuDistroHandler) SELinuxSupported() bool { return false } diff --git a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go index f58e07df4..123040055 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go +++ b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go @@ -703,6 +703,14 @@ func customizeImageHelper(ctx context.Context, rc *ResolvedConfig, partitionsCus } defer imageConnection.Close() + // Clear btrfs read-only subvolume properties so that IC can write to + // partitions that were sealed at build time (e.g. ACL's USR-A). + // The property will be restored later when verity is recalculated. + err = clearBtrfsReadOnlyProperties(imageConnection) + if err != nil { + return nil, nil, nil, "", err + } + osRelease, err := extractOSRelease(imageConnection) if err != nil { return nil, nil, nil, "", err @@ -712,7 +720,7 @@ func customizeImageHelper(ctx context.Context, rc *ResolvedConfig, partitionsCus logger.Log.Infof("Base OS distro: %s", distro) logger.Log.Infof("Base OS version: %s", version) - err = validateUkiMode(imageConnection, rc.Uki) + err = validateUkiMode(imageConnection, rc.Uki, distroHandler) if err != nil { return nil, nil, nil, "", err } diff --git a/toolkit/tools/pkg/imagecustomizerlib/imageutils.go b/toolkit/tools/pkg/imagecustomizerlib/imageutils.go index ee6b22d01..f9e99dd2e 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/imageutils.go +++ b/toolkit/tools/pkg/imagecustomizerlib/imageutils.go @@ -10,6 +10,7 @@ import ( "io/fs" "path/filepath" "sort" + "strings" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagegen/configuration" @@ -17,10 +18,13 @@ import ( "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagegen/installutils" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/file" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/imageconnection" + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/logger" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/safechroot" + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/shell" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/sliceutils" "github.com/microsoft/azure-linux-image-tools/toolkit/tools/internal/targetos" "go.opentelemetry.io/otel" + "golang.org/x/sys/unix" ) type installOSFunc func(imageChroot *safechroot.Chroot) error @@ -217,7 +221,7 @@ func createNewImageHelper(targetOs targetos.TargetOs, imageConnection *imageconn func configureDiskBootLoader(imageConnection *imageconnection.ImageConnection, rootMountIdType imagecustomizerapi.MountIdentifierType, bootType imagecustomizerapi.BootType, selinuxConfig imagecustomizerapi.SELinux, kernelCommandLine imagecustomizerapi.KernelCommandLine, - currentSELinuxMode imagecustomizerapi.SELinuxMode, forceGrubMkconfig bool, + currentSELinuxMode imagecustomizerapi.SELinuxMode, forceGrubMkconfig bool, distroHandler DistroHandler, ) error { imagerBootType, err := bootTypeToImager(bootType) if err != nil { @@ -237,12 +241,12 @@ func configureDiskBootLoader(imageConnection *imageconnection.ImageConnection, useGrubMkconfig := forceGrubMkconfig if !forceGrubMkconfig { // Detect the boot configuration type to determine whether to use grub mkconfig. - grubCfgContent, err := readGrub2ConfigFile(imageConnection.Chroot(), installutils.FedoraGrubCfgFile) + grubCfgContent, err := distroHandler.ReadGrub2ConfigFile(imageConnection.Chroot()) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } - bootConfigType, err := determineBootConfigType(grubCfgContent, imageConnection.Chroot()) + bootConfigType, err := determineBootConfigType(grubCfgContent, imageConnection.Chroot(), distroHandler) if err != nil { return err } @@ -381,11 +385,59 @@ func createPartIdToPartUuidMap(partIDToDevPathMap map[string]string, diskPartiti } func extractOSRelease(imageConnection *imageconnection.ImageConnection) (string, error) { + // Try /etc/os-release first, then fall back to /usr/lib/os-release. + // The fallback is per the os-release(5) spec and is needed for distros like + // ACL where /etc is an overlay that may not be mounted during customization. osReleasePath := filepath.Join(imageConnection.Chroot().RootDir(), "etc/os-release") data, err := file.Read(osReleasePath) if err != nil { - return "", fmt.Errorf("failed to read /etc/os-release:\n%w", err) + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("failed to read /etc/os-release:\n%w", err) + } + osReleasePath = filepath.Join(imageConnection.Chroot().RootDir(), "usr/lib/os-release") + data, err = file.Read(osReleasePath) + if err != nil { + return "", fmt.Errorf("failed to read os-release (tried /etc/os-release and /usr/lib/os-release):\n%w", err) + } } return string(data), nil } + +// clearBtrfsReadOnlyProperties clears the btrfs read-only subvolume property on +// any btrfs mount that IC mounted as read-write. Some distros (e.g. ACL) set +// this property at build time to make partitions immutable at runtime. +func clearBtrfsReadOnlyProperties(imageConnection *imageconnection.ImageConnection) error { + for _, mp := range imageConnection.Chroot().GetMountPoints() { + if mp.GetFSType() != "btrfs" { + continue + } + + // Skip mounts that IC intentionally mounted read-only. + if (mp.GetFlags() & unix.MS_RDONLY) != 0 { + continue + } + + mountPath := filepath.Join(imageConnection.Chroot().RootDir(), mp.GetTarget()) + + // Check if the subvolume is read-only. + stdout, stderr, err := shell.Execute("btrfs", "property", "get", "-ts", mountPath, "ro") + if err != nil { + // Not all btrfs mounts have subvolumes; skip on error. + logger.Log.Debugf("Skipping btrfs property check on %s: %v: %s", mp.GetTarget(), err, stderr) + continue + } + + if strings.TrimSpace(stdout) != "ro=true" { + continue + } + + logger.Log.Debugf("Clearing btrfs read-only property on %s", mp.GetTarget()) + err = shell.ExecuteLive(true, "btrfs", "property", "set", "-ts", mountPath, "ro", "false") + if err != nil { + return fmt.Errorf("failed to clear btrfs read-only property on %s:\n%w", mp.GetTarget(), err) + } + } + + return nil +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/liveosisobuilder.go b/toolkit/tools/pkg/imagecustomizerlib/liveosisobuilder.go index 4e4649798..193572512 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/liveosisobuilder.go +++ b/toolkit/tools/pkg/imagecustomizerlib/liveosisobuilder.go @@ -203,8 +203,14 @@ func createLiveOSFromRawHelper(ctx context.Context, buildDir string, inputArtifa } defer rawImageConnection.Close() + // Detect the distro handler for the image. + distroHandler, err := NewDistroHandlerFromChroot(rawImageConnection.Chroot()) + if err != nil { + return fmt.Errorf("failed to detect distribution:\n%w", err) + } + // Check if the base image is a UKI image - hasUkis, err := baseImageHasUkis(rawImageConnection.Chroot()) + hasUkis, err := baseImageHasUkis(rawImageConnection.Chroot(), distroHandler) if err != nil { return fmt.Errorf("failed to check if base image has UKIs:\n%w", err) } @@ -213,11 +219,6 @@ func createLiveOSFromRawHelper(ctx context.Context, buildDir string, inputArtifa } // Find out if selinux is enabled - distroHandler, err := NewDistroHandlerFromChroot(rawImageConnection.Chroot()) - if err != nil { - return fmt.Errorf("failed to detect distribution:\n%w", err) - } - bootCustomizer, err := NewBootCustomizer(rawImageConnection.Chroot(), nil, isoBuildDir, distroHandler) if err != nil { return fmt.Errorf("failed to attach to raw image to inspect selinux status:\n%w", err) diff --git a/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go b/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go index 577eb2a19..635d787b3 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go +++ b/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go @@ -203,6 +203,19 @@ func findFstabInRoot(diskPartition diskutils.PartitionInfo, tmpDir string) ([]st matchingPaths = append(matchingPaths, "") } + // Check for IC-specific fstab. + // ACL places an fstab at /usr/share/ic/etc/fstab on the USR partition so + // that IC can discover the partition layout without /etc being available. + // The path is unique enough to avoid false matches on non-ACL distros. + fstabIcPath := filepath.Join(tmpDir, "share/ic/etc/fstab") + exists, err = file.PathExists(fstabIcPath) + if err != nil { + return nil, err + } + if exists { + matchingPaths = append(matchingPaths, "share/ic") + } + if diskPartition.FileSystemType == "btrfs" { // List actual subvolumes using btrfs tools subvolumes, err := listBtrfsSubvolumes(tmpDir) @@ -781,7 +794,7 @@ func extractKernelCmdlineFromUki(espPartition *diskutils.PartitionInfo, func getUkiFiles(espPath string) ([]string, error) { espLinuxPath := filepath.Join(espPath, UkiOutputDir) - searchPattern := filepath.Join(espLinuxPath, "vmlinuz-*.efi") + searchPattern := filepath.Join(espLinuxPath, "*.efi") logger.Log.Debugf("Searching for UKI files: pattern=(%s)", searchPattern) ukiFiles, err := filepath.Glob(searchPattern)