diff --git a/cmd/os-image-composer/build.go b/cmd/os-image-composer/build.go index c768ee0f..89637248 100644 --- a/cmd/os-image-composer/build.go +++ b/cmd/os-image-composer/build.go @@ -10,6 +10,7 @@ import ( "github.com/open-edge-platform/os-image-composer/internal/provider/azl" "github.com/open-edge-platform/os-image-composer/internal/provider/elxr" "github.com/open-edge-platform/os-image-composer/internal/provider/emt" + "github.com/open-edge-platform/os-image-composer/internal/provider/rcd" "github.com/open-edge-platform/os-image-composer/internal/provider/ubuntu" "github.com/open-edge-platform/os-image-composer/internal/utils/logger" "github.com/open-edge-platform/os-image-composer/internal/utils/system" @@ -153,6 +154,10 @@ func InitProvider(os, dist, arch string) (provider.Provider, error) { if err := ubuntu.Register(os, dist, arch); err != nil { return nil, fmt.Errorf("registering ubuntu provider failed: %v", err) } + case rcd.OsName: + if err := rcd.Register(os, dist, arch); err != nil { + return nil, fmt.Errorf("registering rcd provider failed: %v", err) + } default: return nil, fmt.Errorf("unsupported provider: %s", os) } diff --git a/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/chrootenv_aarch64.yml b/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/chrootenv_aarch64.yml new file mode 100644 index 00000000..2500d0c8 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/chrootenv_aarch64.yml @@ -0,0 +1,10 @@ +packages: + - createrepo_c + - centos-gpg-keys # Provides GPG keys for package verification + - centos-stream-release # Provides repo files and release info + - bash # The shell + - coreutils-single # Minimal version of basic tools (ls, cp, mv) + - glibc-minimal-langpack # Core libraries without extra languages + - dnf # Package manager (or 'microdnf' for even smaller) + - rpm # Package database tools + - findutils # Basic search tools (often needed by scripts) \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/chrootenv_x86_64.yml b/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/chrootenv_x86_64.yml new file mode 100644 index 00000000..2500d0c8 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/chrootenv_x86_64.yml @@ -0,0 +1,10 @@ +packages: + - createrepo_c + - centos-gpg-keys # Provides GPG keys for package verification + - centos-stream-release # Provides repo files and release info + - bash # The shell + - coreutils-single # Minimal version of basic tools (ls, cp, mv) + - glibc-minimal-langpack # Core libraries without extra languages + - dnf # Package manager (or 'microdnf' for even smaller) + - rpm # Package database tools + - findutils # Basic search tools (often needed by scripts) \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/local.repo b/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/local.repo new file mode 100644 index 00000000..f1c047ef --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/chrootenvconfigs/local.repo @@ -0,0 +1,7 @@ +[cache-repo] +name=Local Cache Repo +baseurl=file:///cdrom/cache-repo +enabled=1 +gpgcheck=0 +skip_if_unavailable=1 +sslverify=0 \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/config.yml b/config/osv/redhat-compatible-distro/el10/config.yml new file mode 100644 index 00000000..3883eea1 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/config.yml @@ -0,0 +1,16 @@ +# RHEL 10 OS Configuration +# This file defines architecture-specific build configurations + +x86_64: + dist: rcd10 # Distribution identifier + arch: x86_64 # Target architecture + pkgType: rpm # Package management system + chrootenvConfigFile: chrootenvconfigs/chrootenv_x86_64.yml # Path to chrootenv config + releaseVersion: "10.0" # Distribution release version + +aarch64: + dist: rcd10 # Distribution identifier + arch: aarch64 # Target architecture + pkgType: rpm # Package management system + chrootenvConfigFile: chrootenvconfigs/chrootenv_aarch64.yml # Path to chrootenv config + releaseVersion: "10.0" # Distribution release version \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/99-dhcp-en.network b/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/99-dhcp-en.network new file mode 100644 index 00000000..14470999 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/99-dhcp-en.network @@ -0,0 +1,9 @@ +[Match] +Name=e* + +[Network] +DHCP=yes +IPv6AcceptRA=no + +[DHCPv4] +SendRelease=false diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/getty@.service b/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/getty@.service new file mode 100644 index 00000000..a92691e0 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/getty@.service @@ -0,0 +1,7 @@ +[Service] +ExecStart= +ExecStart=-/sbin/agetty --noclear --autologin root --keep-baud %I $TERM + +# Default to tty1 but allow other choices +[Install] +Alias=getty.target.wants/getty@tty1.service \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/serial-getty@.service b/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/serial-getty@.service new file mode 100644 index 00000000..7f0db6aa --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/additionalfiles/serial-getty@.service @@ -0,0 +1,7 @@ +[Service] +ExecStart= +ExecStart=-/sbin/agetty --noclear --autologin root --keep-baud %I $TERM + +# Default to ttyS0 but allow other choices +[Install] +Alias=getty.target.wants/serial-getty@ttyS0.service \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-initrd-x86_64.yml b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-initrd-x86_64.yml new file mode 100644 index 00000000..6a58b05c --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-initrd-x86_64.yml @@ -0,0 +1,52 @@ +image: + name: rcd10-default-x86_64 + version: "1.0.0" + +target: + os: redhat-compatible-distro # Target OS name + dist: el10 # Target OS distribution + arch: x86_64 # Target OS architecture + imageType: img # Image type, valid value: [raw, iso, img]. + +systemConfig: + name: Default_Initrd + description: Default yml configuration for initrd image + + bootloader: + bootType: efi # (efi or legacy) + provider: grub # (grub for efi and legacy mode, or systemd-boot for efi mode) + + packages: + - core-packages-base-image + - dosfstools + - efibootmgr + - grub2-efi + - grub2-efi-binary + - grub2-pc + - ca-certificates + - cronie-anacron + - logrotate + - shadow-utils + - util-linux + + additionalFiles: + - local: ../additionalfiles/99-dhcp-en.network + final: /etc/systemd/network/99-dhcp-en.network + - local: ../additionalfiles/getty@.service + final: /usr/lib/systemd/system/getty@.service + - local: ../additionalfiles/serial-getty@.service + final: /usr/lib/systemd/system/serial-getty@.service + - local: ../../../../../general/isolinux/attendedinstaller + final: /root/attendedinstaller + - local: ../../../../../../build/live-installer + final: /usr/bin/live-installer + + users: + - name: root + startupScript: "/root/attendedinstaller" + + kernel: + name: kernel + cmdline: "console=ttyS0,115200 console=tty0 loglevel=7" + packages: + - kernel \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-iso-x86_64.yml b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-iso-x86_64.yml new file mode 100644 index 00000000..58367ab4 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-iso-x86_64.yml @@ -0,0 +1,122 @@ +image: + name: rcd10-default-x86_64 + version: "1.0.0" + +target: + os: redhat-compatible-distro # Target OS name + dist: el10 # Target OS distribution + arch: x86_64 # Target OS architecture + imageType: iso # Image type, valid value: [raw, iso]. + +disk: + name: Default_ISO + partitionTableType: gpt # Partition table type, valid value: [gpt, mbr] + partitions: # Required for raw, optional for ISO, not needed for rootfs. + - id: boot + type: esp + flags: + - esp + - boot + start: 1MiB + end: 513MiB + fsType: fat32 + mountPoint: /boot/efi + + - id: rootfs + type: linux-root-amd64 + start: 513MiB + end: "0" # 0 means use the rest of the disk space + fsType: ext4 + mountPoint: / + +systemConfig: # Required + name: Default_ISO + description: Default yml configuration for iso image + + initramfs: + template: default-initrd-x86_64.yml + + bootloader: + bootType: efi # (efi or legacy) + provider: grub # (grub for efi and legacy mode, or systemd-boot for efi mode) + + packages: + # grub2-mkconfig + - grub2 + + # developer-packages + - build-essential + - cmake + - createrepo_c + - curl-devel + - device-mapper + - flex + - fuse-devel + - git + - golang + - iputils + - less + - linux-firmware + - net-tools + - ninja-build + - parted + - pciutils + - python3-pip + - tar + - texinfo + - usbutils + + # virtualization-host-packages + - qemu-kvm + - qemu-img + + # core-packages-image + - shim + - grub2-efi-binary + - ca-certificates + - cronie-anacron + - logrotate + - core-packages-base-image + - dracut-hostonly + - dracut-vrf + - initramfs + - shadow-utils + + # core-tools-packages + - dnf + - vim + - wget + + # hyperv packages + - dracut-hyperv + - hyperv-daemons + + # ssh-server + - openssh-server + + # selinux-full + - selinux-policy + - selinux-policy-devel + - policycoreutils-python-utils + - checkpolicy + - secilc + - setools-console + + # drtm + - tboot + - tpm2-tools + - tpm2-tss + + # virt-guest-packages + - dracut-virtio + - dracut-xen + + additionalFiles: + - local: ../additionalfiles/99-dhcp-en.network + final: /etc/systemd/network/99-dhcp-en.network + + kernel: + name: kernel + cmdline: "console=ttyS0,115200 console=tty0 loglevel=7" + packages: + - kernel \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-raw-aarch64.yml b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-raw-aarch64.yml new file mode 100644 index 00000000..7e739829 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-raw-aarch64.yml @@ -0,0 +1,99 @@ +image: + name: rcd10-default-aarch64 + version: "1.0.0" + +target: + os: redhat-compatible-distro # Target OS name + dist: el10 # Target OS distribution + arch: aarch64 # Target OS architecture + imageType: raw # Image type, valid value: [raw, iso]. + +disk: + name: Default_Raw # 1:1 mapping to the systemConfigs name + artifacts: + - + type: raw # image file format + compression: gz # image compression format (optional) + size: 4GiB # 4G, 4GB, 4096 MiB also valid. (Required for raw) + partitionTableType: gpt # Partition table type, valid value: [gpt, mbr] + partitions: # Required for raw, optional for ISO, not needed for rootfs. + - id: boot + type: esp + flags: + - esp + - boot + start: 1MiB + end: 513MiB + fsType: fat32 + mountPoint: /boot/efi + mountOptions: umask=0077 + + - id: rootfs + type: linux-root-amd64 + start: 513MiB + end: 2561MiB # 513MiB + 2GiB (2048MiB) = 2561MiB + fsType: ext4 + mountPoint: / + mountOptions: defaults, ro + + - id: roothashmap + type: linux + start: 2561MiB + end: 3061MiB # 2561MiB + 500MiB = 3061MiB + fsType: ext4 + mountPoint: none + + - id: userdata + type: linux + start: 3061MiB + end: "0" # 2561MiB + 2GiB (2048MiB) = 4609MiB + fsType: ext4 + mountPoint: /opt + +systemConfig: + name: Default_Raw + description: Default yml configuration for raw image + + bootloader: + bootType: efi # (efi or legacy) + provider: systemd-boot # (grub for efi and legacy mode, or systemd-boot for efi mode) + + immutability: + enabled: true # default is true + + packages: + # --- MINIMUM BOOTABLE BASE --- + - basesystem # The skeleton of the OS structure + - filesystem # Provides the basic directory layout (/, /etc, /usr, etc.) + - setup # Contains fundamental system configuration files (like /etc/passwd) + - glibc # The core C library for almost all binaries + - bash # The shell required for init scripts + - coreutils # Basic commands (ls, cp, mkdir) required during boot + - systemd # The init system (PID 1) + - util-linux # Required for mounting filesystems (mount, fdisk) + - kernel # The Linux kernel itself + - kernel-core # Essential kernel modules for hardware/filesystem support + - dracut # Used to generate the initramfs to find the root disk + - dnf # Even a minimum build needs a way to install more packages + + # --- BOOTLOADER (For UEFI/UKI) --- + - grub2-common # The EFI boot manager + - shim-x64 # Necessary for UEFI handover + + # --- MINIMUM CONFIGURATION --- + - hostname # To identify the system + - shadow-utils # To manage users/root password + - iproute # Minimum networking tool to bring up an interface + - ncurses # Required for terminal display + + additionalFiles: + - local: ../additionalfiles/99-dhcp-en.network + final: /etc/systemd/network/99-dhcp-en.network + + kernel: + name: kernel + cmdline: "console=ttyS0,115200 console=tty0 loglevel=7" + enableExtraModules: "usbcore usb-common" + packages: + - kernel + uki: true diff --git a/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-raw-x86_64.yml b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-raw-x86_64.yml new file mode 100644 index 00000000..98df3f1f --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/imageconfigs/defaultconfigs/default-raw-x86_64.yml @@ -0,0 +1,118 @@ +image: + name: rcd10-default-x86_64 + version: "1.0.0" + +target: + os: redhat-compatible-distro # Target OS name + dist: el10 # Target OS distribution + arch: x86_64 # Target OS architecture + imageType: raw # Image type, valid value: [raw, iso]. + +packageRepositories: + - codename: "CentOS-Stream-10-AppStream" + url: "https://mirror.stream.centos.org/10-stream/AppStream/x86_64/os" + pkey: "https://www.centos.org/keys/RPM-GPG-KEY-centosofficial-SHA256" + +disk: + name: Default_Raw # 1:1 mapping to the systemConfigs name + artifacts: + - + type: raw # image file format + compression: gz # image compression format (optional) + size: 4GiB # 4G, 4GB, 4096 MiB also valid. (Required for raw) + partitionTableType: gpt # Partition table type, valid value: [gpt, mbr] + partitions: # Required for raw, optional for ISO, not needed for rootfs. + - id: boot + type: esp + flags: + - esp + - boot + start: 1MiB + end: 513MiB + fsType: fat32 + mountPoint: /boot/efi + mountOptions: umask=0077 + + - id: rootfs + type: linux-root-amd64 + start: 513MiB + end: 2561MiB # 513MiB + 2GiB (2048MiB) = 2561MiB + fsType: ext4 + mountPoint: / + mountOptions: defaults, ro + + - id: roothashmap + type: linux + start: 2561MiB + end: 3061MiB # 2561MiB + 500MiB = 3061MiB + fsType: ext4 + mountPoint: none + + - id: userdata + type: linux + start: 3061MiB + end: "0" # 2561MiB + 2GiB (2048MiB) = 4609MiB + fsType: ext4 + mountPoint: /opt + +systemConfig: + name: Default_Raw + description: Default yml configuration for raw image + + bootloader: + bootType: efi # (efi or legacy) + provider: systemd-boot # (grub for efi and legacy mode, or systemd-boot for efi mode) + + immutability: + enabled: false # default is true + + packages: + # --- MINIMUM BOOTABLE BASE --- + - centos-stream-release # <--- ADDED: Critical for system identity + - basesystem + - filesystem + - setup + - glibc + - bash + - coreutils + - systemd + - util-linux + - kernel + - kernel-core + - kernel-modules # <--- ADDED: For hardware driver support + - dracut + - cpio + - dracut-config-generic # <--- ADDED: Ensures the image boots on any hardware + - dnf + + # --- BOOTLOADER GRUB (For UEFI) --- + # - grub2-common + # - grub2-efi-x64 # <--- ADDED: The actual EFI bootloader + # - grub2-tools # <--- ADDED: Tools to generate grub.cfg + # - shim-x64 + # - efibootmgr # <--- ADDED: Required to register boot entry + + # --- SYSTEMD-BOOT LOADER --- + - systemd-ukify # <--- Provides the 'ukify' command + - systemd-boot-unsigned # The boot manager + - shim-x64 # For Secure Boot handoff + - binutils # Required by ukify for 'objcopy' operations + - python3-pefile # Often a required dependency for ukify to handle PE binaries + + # --- MINIMUM CONFIGURATION --- + - hostname + - shadow-utils + - iproute + - ncurses + + additionalFiles: + - local: ../additionalfiles/99-dhcp-en.network + final: /etc/systemd/network/99-dhcp-en.network + + kernel: + name: kernel + cmdline: "console=ttyS0,115200 console=tty0 loglevel=7" + enableExtraModules: "usbcore usb-common" + packages: + - kernel + uki: true \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/providerconfigs/aarch64_64_repo.yml b/config/osv/redhat-compatible-distro/el10/providerconfigs/aarch64_64_repo.yml new file mode 100644 index 00000000..5ef05359 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/providerconfigs/aarch64_64_repo.yml @@ -0,0 +1,13 @@ +# RCD 10.0 Repository Configuration +name: "RCD 10.0 Repository" +type: "rpm" # Repository type: rpm or deb +baseURL: "https://mirror.stream.centos.org/10-stream/BaseOS/{arch}/os" +component: "rcd-10.0-base" # Repository component/section identifier +gpgCheck: true # Re-enabled with correct GPG key +repoGPGCheck: true # Re-enabled with correct GPG key +enabled: true +gpgKey: "https://www.centos.org/keys/RPM-GPG-KEY-CentOS-Official" +# For RPM-based repositories, we use the baseURL pattern +# The actual repository URL is constructed as: {baseURL}/{arch}/ +# Repodata is accessed at: {baseURL}/{arch}/repodata/repomd.xml +buildPath: "./builds/rcd10" # Will be replaced with temp_dir/builds/rcd10 at runtime \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/providerconfigs/repo.yml b/config/osv/redhat-compatible-distro/el10/providerconfigs/repo.yml new file mode 100644 index 00000000..5ef05359 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/providerconfigs/repo.yml @@ -0,0 +1,13 @@ +# RCD 10.0 Repository Configuration +name: "RCD 10.0 Repository" +type: "rpm" # Repository type: rpm or deb +baseURL: "https://mirror.stream.centos.org/10-stream/BaseOS/{arch}/os" +component: "rcd-10.0-base" # Repository component/section identifier +gpgCheck: true # Re-enabled with correct GPG key +repoGPGCheck: true # Re-enabled with correct GPG key +enabled: true +gpgKey: "https://www.centos.org/keys/RPM-GPG-KEY-CentOS-Official" +# For RPM-based repositories, we use the baseURL pattern +# The actual repository URL is constructed as: {baseURL}/{arch}/ +# Repodata is accessed at: {baseURL}/{arch}/repodata/repomd.xml +buildPath: "./builds/rcd10" # Will be replaced with temp_dir/builds/rcd10 at runtime \ No newline at end of file diff --git a/config/osv/redhat-compatible-distro/el10/providerconfigs/x86_64_repo.yml b/config/osv/redhat-compatible-distro/el10/providerconfigs/x86_64_repo.yml new file mode 100644 index 00000000..5ef05359 --- /dev/null +++ b/config/osv/redhat-compatible-distro/el10/providerconfigs/x86_64_repo.yml @@ -0,0 +1,13 @@ +# RCD 10.0 Repository Configuration +name: "RCD 10.0 Repository" +type: "rpm" # Repository type: rpm or deb +baseURL: "https://mirror.stream.centos.org/10-stream/BaseOS/{arch}/os" +component: "rcd-10.0-base" # Repository component/section identifier +gpgCheck: true # Re-enabled with correct GPG key +repoGPGCheck: true # Re-enabled with correct GPG key +enabled: true +gpgKey: "https://www.centos.org/keys/RPM-GPG-KEY-CentOS-Official" +# For RPM-based repositories, we use the baseURL pattern +# The actual repository URL is constructed as: {baseURL}/{arch}/ +# Repodata is accessed at: {baseURL}/{arch}/repodata/repomd.xml +buildPath: "./builds/rcd10" # Will be replaced with temp_dir/builds/rcd10 at runtime \ No newline at end of file diff --git a/image-templates/rcd10-x86_64-dlstreamer.yml b/image-templates/rcd10-x86_64-dlstreamer.yml new file mode 100644 index 00000000..3252d0d7 --- /dev/null +++ b/image-templates/rcd10-x86_64-dlstreamer.yml @@ -0,0 +1,60 @@ +image: + name: rcd10-x86_64-dlstreamer + version: "1.0.0" + +target: + os: redhat-compatible-distro # Target OS name + dist: el10 # Target OS distribution + arch: x86_64 # Target OS architecture + imageType: raw # Image type, valid value: [raw, iso]. + +packageRepositories: + - codename: "edge-base" + url: "https://files-rs.edgeorchestration.intel.com/files-edge-orch/microvisor/rpms/3.0/base" + pkey: "https://raw.githubusercontent.com/open-edge-platform/edge-microvisor-toolkit/refs/heads/3.0/SPECS/edge-repos/INTEL-RPM-GPG-KEY" # Uncomment and replace in real config + AllowPackages: + - kernel-drivers-gpu + - kernel + - grub2-configuration + +disk: + name: Minimal_Raw # 1:1 mapping to the systemConfigs name + artifacts: + - + type: raw # image file format, valid value [raw, vhd, vhdx, qcow2, vmdk, vdi] + compression: gz # image compression format (optional) + - type: vhdx + size: 4GiB # 4G, 4GB, 4096 MiB also valid. (Required for raw) + partitionTableType: gpt # Partition table type, valid value: [gpt, mbr] + partitions: # Required for raw, optional for ISO, not needed for rootfs. + - id: boot + type: esp + flags: + - esp + - boot + start: 1MiB + end: 513MiB + fsType: fat32 + mountPoint: /boot/efi + mountOptions: umask=0077 + + - id: rootfs + type: linux-root-amd64 + start: 513MiB + end: "0" + fsType: ext4 + mountPoint: / + mountOptions: defaults + +systemConfig: + name: Minimal_Raw + description: Default yml configuration for raw image + kernel: + version: "6.12" + cmdline: "console=ttyS0,115200 console=tty0 loglevel=7 i915.force_probe=*" + enableExtraModules: "usbcore usb-common" + packages: + - kernel-drivers-gpu + + immutability: + enabled: false # default is true, overridden to false here \ No newline at end of file diff --git a/image-templates/rcd10-x86_64-minimal-raw.yml b/image-templates/rcd10-x86_64-minimal-raw.yml new file mode 100644 index 00000000..26cb4f00 --- /dev/null +++ b/image-templates/rcd10-x86_64-minimal-raw.yml @@ -0,0 +1,44 @@ +image: + name: rcd10-x86_64-minimal + version: "1.0.0" + +target: + os: redhat-compatible-distro # Target OS name + dist: el10 # Target OS distribution + arch: x86_64 # Target OS architecture + imageType: raw # Image type, valid value: [raw, iso]. + +disk: + name: Minimal_Raw # 1:1 mapping to the systemConfigs name + artifacts: + - + type: raw # image file format, valid value [raw, vhd, vhdx, qcow2, vmdk, vdi] + compression: gz # image compression format (optional) + - type: vhdx + size: 4GiB # 4G, 4GB, 4096 MiB also valid. (Required for raw) + partitionTableType: gpt # Partition table type, valid value: [gpt, mbr] + partitions: # Required for raw, optional for ISO, not needed for rootfs. + - id: boot + type: esp + flags: + - esp + - boot + start: 1MiB + end: 513MiB + fsType: fat32 + mountPoint: /boot/efi + mountOptions: umask=0077 + + - id: rootfs + type: linux-root-amd64 + start: 513MiB + end: "0" + fsType: ext4 + mountPoint: / + mountOptions: defaults + +systemConfig: + name: Minimal_Raw + description: Default yml configuration for raw image + immutability: + enabled: false # default is true, overridden to false here \ No newline at end of file diff --git a/internal/chroot/chrootenv.go b/internal/chroot/chrootenv.go index be6245e4..ad9d6c58 100644 --- a/internal/chroot/chrootenv.go +++ b/internal/chroot/chrootenv.go @@ -57,6 +57,7 @@ type ChrootEnv struct { ChrootEnvRoot string ChrootImageBuildDir string ChrootBuilder chrootbuild.ChrootBuilderInterface + TargetOs string // Store targetOs for package manager selection } func NewChrootEnv(targetOs, targetDist, targetArch string) (*ChrootEnv, error) { @@ -80,6 +81,7 @@ func NewChrootEnv(targetOs, targetDist, targetArch string) (*ChrootEnv, error) { return &ChrootEnv{ ChrootEnvRoot: chrootEnvRoot, ChrootBuilder: chrootBuilder, + TargetOs: targetOs, }, nil } @@ -277,8 +279,16 @@ func (chrootEnv *ChrootEnv) RefreshLocalCacheRepo() error { // From local.repo pkgType := chrootEnv.GetTargetOsPkgType() if pkgType == "rpm" { + pkgManager := chrootEnv.getPackageManagerCmd() releaseVersion := chrootEnv.GetTargetOsReleaseVersion() - cmd := fmt.Sprintf("tdnf makecache --releasever %s", releaseVersion) + + var cmd string + if pkgManager == "dnf" { + cmd = "dnf makecache" + } else { + cmd = fmt.Sprintf("tdnf makecache --releasever %s", releaseVersion) + } + if _, err := shell.ExecCmdWithStream(cmd, true, chrootEnv.ChrootEnvRoot, nil); err != nil { return fmt.Errorf("failed to refresh cache for chroot repository: %w", err) } @@ -480,22 +490,55 @@ func (chrootEnv *ChrootEnv) CleanupChrootEnv(targetOs, targetDist, targetArch st return nil } +// getPackageManagerCmd returns the appropriate package manager command based on target OS +func (chrootEnv *ChrootEnv) getPackageManagerCmd() string { + if strings.Contains(chrootEnv.TargetOs, "redhat-compatible-distro") { + return "dnf" + } + return "tdnf" +} + +// buildInstallCmd builds the package installation command based on the package manager +func (chrootEnv *ChrootEnv) buildInstallCmd(packageName, chrootInstallRoot string, repositoryIDList []string) string { + pkgManager := chrootEnv.getPackageManagerCmd() + releaseVersion := chrootEnv.GetTargetOsReleaseVersion() + + if pkgManager == "dnf" { + // dnf syntax for RCD builds (similar to tdnf but with dnf) + installCmd := fmt.Sprintf("dnf install %s -y --nogpgcheck --installroot %s --setopt=reposdir=%s", + packageName, chrootInstallRoot, RPMRepoConfigDir) + + // Add repository configuration for dnf + if len(repositoryIDList) > 0 { + installCmd += " --disablerepo=*" + for _, repoID := range repositoryIDList { + installCmd += " --enablerepo=" + repoID + } + } + return installCmd + } else { + // tdnf original syntax + installCmd := fmt.Sprintf("tdnf install %s --releasever %s --setopt reposdir=%s --nogpgcheck --assumeyes --installroot %s", + packageName, releaseVersion, RPMRepoConfigDir, chrootInstallRoot) + + // Add repository configuration for tdnf + if len(repositoryIDList) > 0 { + installCmd += " --disablerepo=*" + for _, repoID := range repositoryIDList { + installCmd += " --enablerepo=" + repoID + } + } + return installCmd + } +} + func (chrootEnv *ChrootEnv) TdnfInstallPackage(packageName, installRoot string, repositoryIDList []string) error { - var installCmd string chrootInstallRoot, err := chrootEnv.GetChrootEnvPath(installRoot) if err != nil { return fmt.Errorf("failed to get chroot environment path for install root %s: %w", installRoot, err) } - releaseVersion := chrootEnv.GetTargetOsReleaseVersion() - installCmd = fmt.Sprintf("tdnf install %s --releasever %s --setopt reposdir=%s --nogpgcheck --assumeyes --installroot %s", - packageName, releaseVersion, RPMRepoConfigDir, chrootInstallRoot) - if len(repositoryIDList) > 0 { - installCmd += " --disablerepo=*" - for _, repoID := range repositoryIDList { - installCmd += " --enablerepo=" + repoID - } - } + installCmd := chrootEnv.buildInstallCmd(packageName, chrootInstallRoot, repositoryIDList) if _, err := shell.ExecCmdWithStream(installCmd, true, chrootEnv.ChrootEnvRoot, nil); err != nil { return fmt.Errorf("failed to install package %s: %w", packageName, err) diff --git a/internal/chroot/rpm/installer.go b/internal/chroot/rpm/installer.go index 21eafe9e..fba9264c 100644 --- a/internal/chroot/rpm/installer.go +++ b/internal/chroot/rpm/installer.go @@ -170,10 +170,13 @@ func (rpmInstaller *RpmInstaller) updateRpmDB(chrootEnvBuildPath, chrootPkgCache // importGpgKeys imports GPG keys into the chroot environment func importGpgKeys(targetOs string, chrootEnvBuildPath string) error { var cmdStr string - if targetOs == "edge-microvisor-toolkit" { + switch targetOs { + case "edge-microvisor-toolkit": cmdStr = "rpm -q -l edge-repos-shared | grep 'rpm-gpg'" - } else if targetOs == "azure-linux" { + case "azure-linux": cmdStr = "rpm -q -l azurelinux-repos-shared | grep 'rpm-gpg'" + case "redhat-compatible-distro": + cmdStr = "rpm -q -l centos-gpg-keys | grep 'RPM-GPG-KEY'" } output, err := shell.ExecCmd(cmdStr, false, chrootEnvBuildPath, nil) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eeb5c0e2..95e7fdd8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2977,21 +2977,28 @@ func TestMergePackageRepositories(t *testing.T) { merged := mergePackageRepositories(defaultRepos, userRepos) - // User repos should completely override defaults - if len(merged) != 1 { - t.Errorf("expected 1 merged repository, got %d", len(merged)) + // User repos are appended to defaults (additive merge) + if len(merged) != 3 { + t.Errorf("expected 3 merged repositories (2 default + 1 user), got %d", len(merged)) } - if merged[0].Codename != "user1" { - t.Errorf("expected merged repo codename 'user1', got '%s'", merged[0].Codename) + // Create a map to easily check repos by codename + repoMap := make(map[string]PackageRepository) + for _, repo := range merged { + repoMap[repo.Codename] = repo } - if merged[0].URL != "https://user.com/1" { - t.Errorf("expected merged repo URL 'https://user.com/1', got '%s'", merged[0].URL) + // Verify all default repos are preserved + if repo, exists := repoMap["default1"]; !exists || repo.URL != "https://default.com/1" { + t.Errorf("expected default1 repo to be preserved") + } + if repo, exists := repoMap["default2"]; !exists || repo.URL != "https://default.com/2" { + t.Errorf("expected default2 repo to be preserved") } - if merged[0].PKey != "https://user.com/1.pub" { - t.Errorf("expected merged repo pkey 'https://user.com/1.pub', got '%s'", merged[0].PKey) + // Verify user repo is added + if repo, exists := repoMap["user1"]; !exists || repo.URL != "https://user.com/1" { + t.Errorf("expected user1 repo to be added") } } @@ -3056,25 +3063,29 @@ func TestMergeConfigurationsWithPackageRepositories(t *testing.T) { t.Fatalf("failed to merge configurations: %v", err) } - // Test that user repositories completely override defaults + // Test that user repositories are added to defaults (additive merge) repos := merged.GetPackageRepositories() - if len(repos) != 1 { - t.Errorf("expected 1 merged repository (user override), got %d", len(repos)) + if len(repos) != 3 { + t.Errorf("expected 3 merged repositories (2 default + 1 user), got %d", len(repos)) } - if repos[0].Codename != "company-internal" { - t.Errorf("expected user repository codename 'company-internal', got '%s'", repos[0].Codename) - } - - // Verify default repositories are not included when user specifies repositories + // Verify user repository is included companyRepo := merged.GetRepositoryByCodename("company-internal") if companyRepo == nil { t.Errorf("expected to find user repository 'company-internal'") + } else if companyRepo.URL != "https://packages.company.com/internal" { + t.Errorf("expected company-internal URL to be correct, got '%s'", companyRepo.URL) + } + + // Verify default repositories are preserved + azureExtrasRepo := merged.GetRepositoryByCodename("azure-extras") + if azureExtrasRepo == nil { + t.Errorf("expected default repository 'azure-extras' to be preserved") } - defaultRepo := merged.GetRepositoryByCodename("azure-extras") - if defaultRepo != nil { - t.Errorf("expected default repository 'azure-extras' to be overridden by user repos") + azurePreviewRepo := merged.GetRepositoryByCodename("azure-preview") + if azurePreviewRepo == nil { + t.Errorf("expected default repository 'azure-preview' to be preserved") } } diff --git a/internal/config/merge.go b/internal/config/merge.go index 41b56720..1f2f1580 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -491,10 +491,30 @@ func mergePackageRepositories(defaultRepos, userRepos []PackageRepository) []Pac if len(userRepos) == 0 { return defaultRepos } + if len(defaultRepos) == 0 { + return userRepos + } + + // Start with a copy of default repos + merged := make([]PackageRepository, len(defaultRepos)) + copy(merged, defaultRepos) + + // For each user repo, override if codename matches a default, otherwise append + for _, userRepo := range userRepos { + found := false + for i, defRepo := range merged { + if defRepo.Codename == userRepo.Codename { + merged[i] = userRepo + found = true + break + } + } + if !found { + merged = append(merged, userRepo) + } + } - // User repositories take precedence - they completely override defaults - // This gives users full control over repository configuration - return userRepos + return merged } // Helper functions to check if structures are empty diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go index 78898051..af97a475 100644 --- a/internal/config/merge_test.go +++ b/internal/config/merge_test.go @@ -468,30 +468,29 @@ func TestMergePackageRepositoriesDetailed(t *testing.T) { merged := mergePackageRepositories(defaultRepos, userRepos) - // User repositories completely replace defaults in actual implementation - if len(merged) != 2 { - t.Errorf("expected 2 repositories (user repos replace defaults), got %d", len(merged)) + // User repos are appended to defaults; matching codenames override defaults + if len(merged) != 3 { + t.Errorf("expected 3 repositories (default universe + user main override + user extras appended), got %d", len(merged)) } - // Check that only user repositories are present repoMap := make(map[string]string) for _, repo := range merged { repoMap[repo.Codename] = repo.URL } - // main should be from user + // main should be overridden by user if repoMap["main"] != "http://user.com/main" { t.Errorf("expected main repo to be from user, got '%s'", repoMap["main"]) } - // extras should be from user + // extras should be appended from user if repoMap["extras"] != "http://user.com/extras" { t.Errorf("expected extras repo to be from user, got '%s'", repoMap["extras"]) } - // universe should NOT be present (defaults are completely replaced) - if _, exists := repoMap["universe"]; exists { - t.Errorf("universe repo should not be present (defaults completely replaced)") + // universe should still be present from defaults + if repoMap["universe"] != "http://default.com/universe" { + t.Errorf("expected universe repo from defaults, got '%s'", repoMap["universe"]) } } diff --git a/internal/config/schema/os-config.schema.json b/internal/config/schema/os-config.schema.json index ff49f7f9..ac21c29e 100644 --- a/internal/config/schema/os-config.schema.json +++ b/internal/config/schema/os-config.schema.json @@ -12,7 +12,7 @@ "dist": { "type": "string", "description": "Distribution identifier", - "enum": ["azl3", "emt3", "elxr12", "aria","ubuntu24"] + "enum": ["azl3", "emt3", "elxr12", "aria", "ubuntu24", "rcd10"] }, "arch": { "type": "string", diff --git a/internal/config/schema/os-image-template.schema.json b/internal/config/schema/os-image-template.schema.json index d32bebc5..a0296c66 100644 --- a/internal/config/schema/os-image-template.schema.json +++ b/internal/config/schema/os-image-template.schema.json @@ -29,7 +29,7 @@ "os": { "type": "string", "description": "Target operating system", - "enum": ["azure-linux", "edge-microvisor-toolkit", "wind-river-elxr", "ubuntu"] + "enum": ["azure-linux", "edge-microvisor-toolkit", "wind-river-elxr", "ubuntu", "redhat-compatible-distro"] }, "dist": { "type": "string", diff --git a/internal/image/imageos/imageos.go b/internal/image/imageos/imageos.go index be5db991..bae29052 100644 --- a/internal/image/imageos/imageos.go +++ b/internal/image/imageos/imageos.go @@ -178,6 +178,12 @@ func (imageOs *ImageOs) InstallImageOs(diskPathIdMap map[string]string) (version return } + log.Infof("Image Kernel symlinks creation...") + if err := fixKernelSymlinks(imageOs.installRoot); err != nil { + // Don't fail the build if symlink fix fails, just warn as some distros may not need it + log.Warnf("Failed to fix kernel symlinks: %v (continuing anyway)", err) + } + log.Infof("Image system configuration...") if err = updateImageConfig(imageOs.installRoot, diskPathIdMap, imageOs.template); err != nil { err = fmt.Errorf("failed to update image config: %w", err) @@ -522,6 +528,7 @@ func preImageOsInstall(installRoot string, template *config.ImageTemplate) error func (imageOs *ImageOs) installImagePkgs(installRoot string, template *config.ImageTemplate) error { pkgType := imageOs.chrootEnv.GetTargetOsPkgType() + if pkgType == "rpm" { if err := imageOs.initImageRpmDb(installRoot, template); err != nil { return fmt.Errorf("failed to initialize RPM database: %w", err) @@ -1677,3 +1684,101 @@ func (imageOs *ImageOs) generateSBOM(installRoot string, template *config.ImageT return result, nil } + +// isSymlink checks if a given path is a symbolic link +func isSymlink(path string) (bool, error) { + fileInfo, err := os.Lstat(path) + if err != nil { + return false, err + } + return fileInfo.Mode()&os.ModeSymlink != 0, nil +} + +// fixKernelSymlinks ensures that /boot/vmlinuz-{version} symlinks exist +// pointing to /lib/modules/{version}/vmlinuz. This is normally done by the +// kernel package's post-install script, but that may not run properly in chroot. +func fixKernelSymlinks(installRoot string) error { + log.Debug("Creating kernel symlinks if needed") + bootDir := filepath.Join(installRoot, "boot") + libModulesDir := filepath.Join(installRoot, "lib", "modules") + + // Check if boot directory exists + if _, err := os.Stat(bootDir); os.IsNotExist(err) { + log.Debugf("boot directory does not exist at %s, skipping symlink fix", bootDir) + return nil + } + + // Check if destination directory already has vmlinuz files - if so, ignore and return + bootEntries, err := os.ReadDir(bootDir) + if err == nil { + for _, entry := range bootEntries { + if strings.HasPrefix(entry.Name(), "vmlinuz") { + log.Debugf("Found existing vmlinuz file in boot directory: %s, skipping kernel symlink creation", entry.Name()) + return nil + } + } + } + + // Check if lib/modules directory exists + if _, err := os.Stat(libModulesDir); os.IsNotExist(err) { + log.Debugf("lib/modules directory does not exist at %s, skipping symlink fix", libModulesDir) + return nil + } + + // Read lib/modules directory to find kernel versions + entries, err := os.ReadDir(libModulesDir) + if err != nil { + return fmt.Errorf("failed to read lib/modules directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + kernelVersion := entry.Name() + + // Skip non-kernel version directories (like "build", "source", etc.) + if !strings.Contains(kernelVersion, ".") { + log.Debugf("Skipping non-version directory: %s", kernelVersion) + continue + } + + kernelSourcePath := filepath.Join(libModulesDir, kernelVersion, "vmlinuz") + kernelBootLink := filepath.Join(bootDir, "vmlinuz-"+kernelVersion) + + // Check if the source file exists + if _, err := os.Stat(kernelSourcePath); os.IsNotExist(err) { + log.Debugf("vmlinuz file not found at %s, skipping symlink creation", kernelSourcePath) + continue + } + + // Check if symlink already exists + if _, err := os.Lstat(kernelBootLink); err == nil { + // Symlink or file already exists + if isSymlink, _ := isSymlink(kernelBootLink); isSymlink { + log.Debugf("vmlinuz symlink already exists at %s", kernelBootLink) + continue + } + + // If it's not a symlink, try to replace it + log.Debugf("Non-symlink file exists at %s, removing it", kernelBootLink) + if err := os.Remove(kernelBootLink); err != nil { + log.Warnf("Failed to remove file at %s: %v", kernelBootLink, err) + continue + } + } + + // Create the symlink - use relative path from /boot to /lib/modules + relPath := filepath.Join("..", "..", "lib", "modules", kernelVersion, "vmlinuz") + if err := os.Symlink(relPath, kernelBootLink); err != nil { + log.Warnf("Failed to create symlink from %s to %s: %v", kernelBootLink, relPath, err) + // Don't fail, continue with other kernel versions + continue + } + + log.Infof("Created vmlinuz symlink for kernel %s: %s -> %s", kernelVersion, kernelBootLink, relPath) + } + log.Debug("Finished creating kernel symlinks") + return nil +} diff --git a/internal/image/imageos/imageos_test.go b/internal/image/imageos/imageos_test.go index 62bbd29e..abd52f88 100644 --- a/internal/image/imageos/imageos_test.go +++ b/internal/image/imageos/imageos_test.go @@ -4297,3 +4297,656 @@ func TestSystemConfigurationErrorRecovery(t *testing.T) { }) } } + +// TestIsSymlink tests the isSymlink helper function +func TestIsSymlink(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_issymlink_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create a regular file + regularFile := filepath.Join(tempDir, "regular_file") + if err := os.WriteFile(regularFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create regular file: %v", err) + } + + // Create a symbolic link + symlinkPath := filepath.Join(tempDir, "symlink_file") + if err := os.Symlink(regularFile, symlinkPath); err != nil { + t.Fatalf("Failed to create symlink: %v", err) + } + + // Test regular file (should not be symlink) + isLink, err := isSymlink(regularFile) + if err != nil { + t.Errorf("Unexpected error for regular file: %v", err) + } + if isLink { + t.Error("Regular file incorrectly identified as symlink") + } + + // Test symlink (should be symlink) + isLink, err = isSymlink(symlinkPath) + if err != nil { + t.Errorf("Unexpected error for symlink: %v", err) + } + if !isLink { + t.Error("Symlink incorrectly identified as regular file") + } + + // Test non-existent path + nonExistentPath := filepath.Join(tempDir, "nonexistent") + isLink, err = isSymlink(nonExistentPath) + if err == nil { + t.Error("Expected error for non-existent path") + } + if isLink { + t.Error("Non-existent path incorrectly identified as symlink") + } +} + +// TestFixKernelSymlinksNoBootDir tests fixKernelSymlinks when boot directory doesn't exist +func TestFixKernelSymlinksNoBootDir(t *testing.T) { + // Create test directory without boot directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Test fixKernelSymlinks - should return nil (no error) when boot directory doesn't exist + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error when boot directory doesn't exist, got: %v", err) + } +} + +// TestFixKernelSymlinksNoLibModulesDir tests fixKernelSymlinks when lib/modules directory doesn't exist +func TestFixKernelSymlinksNoLibModulesDir(t *testing.T) { + // Create test directory with boot but no lib/modules directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Test fixKernelSymlinks - should return nil when lib/modules directory doesn't exist + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error when lib/modules directory doesn't exist, got: %v", err) + } +} + +// TestFixKernelSymlinksExistingVmlinuz tests fixKernelSymlinks when vmlinuz already exists in boot +func TestFixKernelSymlinksExistingVmlinuz(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory with existing vmlinuz file + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + existingVmlinuz := filepath.Join(bootDir, "vmlinuz-5.15.0") + if err := os.WriteFile(existingVmlinuz, []byte("kernel"), 0644); err != nil { + t.Fatalf("Failed to create existing vmlinuz file: %v", err) + } + + // Create lib/modules directory with kernel + libModulesDir := filepath.Join(tempDir, "lib", "modules", "5.15.0") + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel"), 0644); err != nil { + t.Fatalf("Failed to create kernel file: %v", err) + } + + // Test fixKernelSymlinks - should return early when vmlinuz already exists + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error when vmlinuz already exists, got: %v", err) + } + + // Verify the existing file wasn't changed + if _, err := os.Stat(existingVmlinuz); os.IsNotExist(err) { + t.Error("Existing vmlinuz file was removed when it shouldn't have been") + } +} + +// TestFixKernelSymlinksCreateSymlinks tests fixKernelSymlinks creating symlinks successfully +func TestFixKernelSymlinksCreateSymlinks(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory (empty) + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directories with multiple kernel versions + kernelVersions := []string{"5.15.0", "6.1.0", "6.2.1"} + for _, version := range kernelVersions { + libModulesDir := filepath.Join(tempDir, "lib", "modules", version) + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules/%s directory: %v", version, err) + } + + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel-"+version), 0644); err != nil { + t.Fatalf("Failed to create kernel file for %s: %v", version, err) + } + } + + // Test fixKernelSymlinks + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error creating symlinks, got: %v", err) + } + + // Verify symlinks were created + for _, version := range kernelVersions { + symlinkPath := filepath.Join(bootDir, "vmlinuz-"+version) + + // Check if symlink exists + if _, err := os.Lstat(symlinkPath); os.IsNotExist(err) { + t.Errorf("Expected symlink %s was not created", symlinkPath) + continue + } + + // Check if it's actually a symlink + isLink, err := isSymlink(symlinkPath) + if err != nil { + t.Errorf("Error checking if %s is symlink: %v", symlinkPath, err) + continue + } + if !isLink { + t.Errorf("Expected %s to be a symlink, but it's not", symlinkPath) + continue + } + + // Check if symlink points to correct target + target, err := os.Readlink(symlinkPath) + if err != nil { + t.Errorf("Error reading symlink target for %s: %v", symlinkPath, err) + continue + } + + expectedTarget := filepath.Join("..", "..", "lib", "modules", version, "vmlinuz") + if target != expectedTarget { + t.Errorf("Symlink %s points to %s, expected %s", symlinkPath, target, expectedTarget) + } + } +} + +// TestFixKernelSymlinksSkipNonVersionDirs tests skipping non-kernel version directories +func TestFixKernelSymlinksSkipNonVersionDirs(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directories with non-version directories and one valid kernel + nonVersionDirs := []string{"build", "source", "extramodules"} + libModulesBase := filepath.Join(tempDir, "lib", "modules") + + for _, dir := range nonVersionDirs { + dirPath := filepath.Join(libModulesBase, dir) + if err := os.MkdirAll(dirPath, 0755); err != nil { + t.Fatalf("Failed to create %s directory: %v", dir, err) + } + } + + // Create one valid kernel version directory + validKernelDir := filepath.Join(libModulesBase, "5.15.0") + if err := os.MkdirAll(validKernelDir, 0755); err != nil { + t.Fatalf("Failed to create valid kernel directory: %v", err) + } + + kernelFile := filepath.Join(validKernelDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel"), 0644); err != nil { + t.Fatalf("Failed to create kernel file: %v", err) + } + + // Test fixKernelSymlinks + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // Verify only the valid kernel symlink was created + symlinkPath := filepath.Join(bootDir, "vmlinuz-5.15.0") + if _, err := os.Lstat(symlinkPath); os.IsNotExist(err) { + t.Error("Expected symlink for valid kernel was not created") + } + + // Verify no symlinks for non-version directories + for _, dir := range nonVersionDirs { + invalidSymlink := filepath.Join(bootDir, "vmlinuz-"+dir) + if _, err := os.Lstat(invalidSymlink); err == nil { + t.Errorf("Unexpected symlink created for non-version directory: %s", dir) + } + } +} + +// TestFixKernelSymlinksSkipMissingKernel tests skipping when kernel file doesn't exist +func TestFixKernelSymlinksSkipMissingKernel(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directory without vmlinuz file + libModulesDir := filepath.Join(tempDir, "lib", "modules", "5.15.0") + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + // Test fixKernelSymlinks + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error when kernel file missing, got: %v", err) + } + + // Verify no symlink was created + symlinkPath := filepath.Join(bootDir, "vmlinuz-5.15.0") + if _, err := os.Lstat(symlinkPath); err == nil { + t.Error("Unexpected symlink created when kernel file was missing") + } +} + +// TestFixKernelSymlinksReplaceNonSymlink tests the replacement logic when boot directory is initially empty +func TestFixKernelSymlinksReplaceNonSymlink(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory (initially empty) + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directory with kernel + libModulesDir := filepath.Join(tempDir, "lib", "modules", "5.15.0") + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("new-kernel"), 0644); err != nil { + t.Fatalf("Failed to create kernel file: %v", err) + } + + // Create a regular file where the symlink should go (after boot dir check passes) + conflictingFile := filepath.Join(bootDir, "vmlinuz-5.15.0") + if err := os.WriteFile(conflictingFile, []byte("old-kernel"), 0644); err != nil { + t.Fatalf("Failed to create conflicting file: %v", err) + } + + // Since the function returns early when it finds vmlinuz* files, we need to test + // this scenario differently. The function is designed to skip entirely if there + // are any vmlinuz files present, so the replacement logic doesn't get executed + // in this case. This is the correct behavior as documented in the function. + + // Test fixKernelSymlinks - should return early due to existing vmlinuz file + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error with existing vmlinuz file, got: %v", err) + } + + // Verify the file remains unchanged (due to early return) + content, err := os.ReadFile(conflictingFile) + if err != nil { + t.Errorf("Error reading conflicting file: %v", err) + } + + if string(content) != "old-kernel" { + t.Errorf("File content changed unexpectedly: got %s, expected 'old-kernel'", string(content)) + } + + // Verify it's still a regular file (not a symlink) + isLink, err := isSymlink(conflictingFile) + if err != nil { + t.Errorf("Error checking if file is symlink: %v", err) + } + if isLink { + t.Error("File was unexpectedly converted to symlink") + } + + // This test verifies that the function correctly implements the early return + // behavior when vmlinuz files are already present, which is the intended design +} + +// TestFixKernelSymlinksKeepExistingSymlink tests keeping existing valid symlinks +func TestFixKernelSymlinksKeepExistingSymlink(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directory with kernel + libModulesDir := filepath.Join(tempDir, "lib", "modules", "5.15.0") + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel"), 0644); err != nil { + t.Fatalf("Failed to create kernel file: %v", err) + } + + // Create existing symlink + existingSymlink := filepath.Join(bootDir, "vmlinuz-5.15.0") + relPath := filepath.Join("..", "..", "lib", "modules", "5.15.0", "vmlinuz") + if err := os.Symlink(relPath, existingSymlink); err != nil { + t.Fatalf("Failed to create existing symlink: %v", err) + } + + // Get original link info + originalInfo, err := os.Lstat(existingSymlink) + if err != nil { + t.Fatalf("Failed to get original symlink info: %v", err) + } + + // Test fixKernelSymlinks + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error with existing symlink, got: %v", err) + } + + // Verify the existing symlink wasn't changed + newInfo, err := os.Lstat(existingSymlink) + if err != nil { + t.Errorf("Existing symlink was removed: %v", err) + } + + // Compare modification times to ensure the symlink wasn't recreated + if !newInfo.ModTime().Equal(originalInfo.ModTime()) { + t.Error("Existing symlink appears to have been recreated") + } + + // Verify it's still a symlink + isLink, err := isSymlink(existingSymlink) + if err != nil { + t.Errorf("Error checking existing symlink: %v", err) + } + if !isLink { + t.Error("Existing symlink is no longer a symlink") + } +} + +// TestFixKernelSymlinksMultipleKernels tests handling multiple kernel versions +func TestFixKernelSymlinksMultipleKernels(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create multiple kernel versions, some with issues + testKernels := map[string]struct { + createKernel bool + expectSymlink bool + description string + }{ + "5.15.0": {true, true, "valid kernel"}, + "6.1.0": {true, true, "another valid kernel"}, + "6.2.0-rc1": {true, true, "rc kernel"}, + "build": {false, false, "non-version directory"}, + "6.3.0": {false, false, "kernel directory without vmlinuz file"}, + } + + libModulesBase := filepath.Join(tempDir, "lib", "modules") + + for version, config := range testKernels { + libModulesDir := filepath.Join(libModulesBase, version) + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules/%s directory: %v", version, err) + } + + if config.createKernel { + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel-"+version), 0644); err != nil { + t.Fatalf("Failed to create kernel file for %s: %v", version, err) + } + } + } + + // Test fixKernelSymlinks + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error with multiple kernels, got: %v", err) + } + + // Verify results for each kernel + for version, config := range testKernels { + symlinkPath := filepath.Join(bootDir, "vmlinuz-"+version) + + if config.expectSymlink { + // Check if symlink exists + if _, err := os.Lstat(symlinkPath); os.IsNotExist(err) { + t.Errorf("Expected symlink %s was not created (%s)", symlinkPath, config.description) + continue + } + + // Check if it's actually a symlink + isLink, err := isSymlink(symlinkPath) + if err != nil { + t.Errorf("Error checking if %s is symlink: %v (%s)", symlinkPath, err, config.description) + continue + } + if !isLink { + t.Errorf("Expected %s to be a symlink (%s)", symlinkPath, config.description) + } + } else { + // Check that symlink was NOT created + if _, err := os.Lstat(symlinkPath); err == nil { + t.Errorf("Unexpected symlink created: %s (%s)", symlinkPath, config.description) + } + } + } +} + +// TestFixKernelSymlinksErrorHandling tests error scenarios +func TestFixKernelSymlinksErrorHandling(t *testing.T) { + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directory with kernel + libModulesDir := filepath.Join(tempDir, "lib", "modules", "5.15.0") + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel"), 0644); err != nil { + t.Fatalf("Failed to create kernel file: %v", err) + } + + // Create a read-only directory to cause symlink creation to fail + symlinkPath := filepath.Join(bootDir, "vmlinuz-5.15.0") + + // First create the symlink directory structure to later make it read-only + readOnlyDir := filepath.Join(bootDir, "readonly_test") + if err := os.MkdirAll(readOnlyDir, 0755); err != nil { + t.Fatalf("Failed to create readonly test dir: %v", err) + } + + // Test with valid setup first + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error in valid setup, got: %v", err) + } + + // Verify symlink was created successfully + if _, err := os.Lstat(symlinkPath); os.IsNotExist(err) { + t.Error("Expected symlink was not created in valid scenario") + } +} + +// TestFixKernelSymlinksPermissionIssues tests handling permission issues gracefully +func TestFixKernelSymlinksPermissionIssues(t *testing.T) { + // Skip this test if running as root, as root can usually override permissions + if os.Getuid() == 0 { + t.Skip("Skipping permission test when running as root") + } + + // Create test directory + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create boot directory and make it read-only + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Create lib/modules directory with kernel + libModulesDir := filepath.Join(tempDir, "lib", "modules", "5.15.0") + if err := os.MkdirAll(libModulesDir, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + kernelFile := filepath.Join(libModulesDir, "vmlinuz") + if err := os.WriteFile(kernelFile, []byte("kernel"), 0644); err != nil { + t.Fatalf("Failed to create kernel file: %v", err) + } + + // Make boot directory read-only to cause symlink creation to fail + if err := os.Chmod(bootDir, 0444); err != nil { + t.Fatalf("Failed to make boot directory read-only: %v", err) + } + defer func() { + if err := os.Chmod(bootDir, 0755); err != nil { + t.Logf("Warning: Failed to restore permissions for %s: %v", bootDir, err) + } + }() // Restore permissions for cleanup + + // Test fixKernelSymlinks - should handle permission errors gracefully + err = fixKernelSymlinks(tempDir) + // The function should not return an error even if individual symlink creations fail + // as it logs warnings and continues + if err != nil { + t.Errorf("Expected function to handle permission errors gracefully, got: %v", err) + } +} + +// TestFixKernelSymlinksEdgeCases tests various edge cases +func TestFixKernelSymlinksEdgeCases(t *testing.T) { + // Test with empty install root + err := fixKernelSymlinks("") + if err != nil { + t.Errorf("Expected no error with empty install root, got: %v", err) + } + + // Create test directory for other edge cases + tempDir, err := os.MkdirTemp("", "test_kernel_symlinks_edge_*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Test with boot directory that exists but is empty + bootDir := filepath.Join(tempDir, "boot") + if err := os.MkdirAll(bootDir, 0755); err != nil { + t.Fatalf("Failed to create boot directory: %v", err) + } + + // Test with lib/modules directory that exists but is empty + libModulesBase := filepath.Join(tempDir, "lib", "modules") + if err := os.MkdirAll(libModulesBase, 0755); err != nil { + t.Fatalf("Failed to create lib/modules directory: %v", err) + } + + // Test fixKernelSymlinks with empty directories + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error with empty directories, got: %v", err) + } + + // Create a file (not directory) in lib/modules + regularFile := filepath.Join(libModulesBase, "regular_file.txt") + if err := os.WriteFile(regularFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create regular file: %v", err) + } + + // Test fixKernelSymlinks - should skip regular files + err = fixKernelSymlinks(tempDir) + if err != nil { + t.Errorf("Expected no error when lib/modules contains regular files, got: %v", err) + } + + // Verify no symlink was created for the regular file + symlinkPath := filepath.Join(bootDir, "vmlinuz-regular_file.txt") + if _, err := os.Lstat(symlinkPath); err == nil { + t.Error("Unexpected symlink created for regular file") + } +} diff --git a/internal/ospackage/rpmutils/download.go b/internal/ospackage/rpmutils/download.go index d23e1d2f..84df7529 100644 --- a/internal/ospackage/rpmutils/download.go +++ b/internal/ospackage/rpmutils/download.go @@ -268,7 +268,7 @@ func createTempGPGKeyFiles(gpgKeyURLs []string) (keyPaths []string, cleanup func log.Infof("fetched GPG key %d (%d bytes) from %s", i+1, len(keyBytes), gpgKeyURL) // Create temp file with unique pattern - tmp, err := os.CreateTemp("", fmt.Sprintf("azurelinux-gpg-%d-*.asc", i)) + tmp, err := os.CreateTemp("", fmt.Sprintf("rpm-gpg-%d-*.asc", i)) if err != nil { // Cleanup any files created so far for _, f := range tempFiles { diff --git a/internal/ospackage/rpmutils/helper.go b/internal/ospackage/rpmutils/helper.go index d97286b2..38e66ca2 100644 --- a/internal/ospackage/rpmutils/helper.go +++ b/internal/ospackage/rpmutils/helper.go @@ -246,13 +246,52 @@ func extractBasePackageNameFromFile(fullName string) string { // extractBaseNameFromDep takes a potentially complex requirement string // and returns only the base package/capability name. func extractBaseNameFromDep(req string) string { - if strings.HasPrefix(req, "(") && strings.Contains(req, " ") { - trimmed := strings.TrimPrefix(req, "(") - parts := strings.Fields(trimmed) + req = strings.TrimSpace(req) + if req == "" { + return "" + } + + // Handle complex conditional dependencies with "if" clauses + if strings.Contains(req, ") if ") { + // Extract content between first '((' and ') if' + if start := strings.Index(req, "(("); start != -1 { + if end := strings.Index(req, ") if "); end != -1 { + inner := req[start+2 : end] + // Handle multiple operators in priority order + for _, op := range []string{" >= ", " <= ", " > ", " < ", " = "} { + if idx := strings.Index(inner, op); idx != -1 { + return strings.TrimSpace(inner[:idx]) + } + } + return strings.TrimSpace(inner) + } + } + } + + // Handle simple parentheses cases + if strings.HasPrefix(req, "(") && strings.HasSuffix(req, ")") { + inner := req[1 : len(req)-1] + inner = strings.TrimSpace(inner) + // Handle version operators in priority order + for _, op := range []string{" >= ", " <= ", " > ", " < ", " = "} { + if idx := strings.Index(inner, op); idx != -1 { + return strings.TrimSpace(inner[:idx]) + } + } + parts := strings.Fields(inner) if len(parts) > 0 { - req = parts[0] + return parts[0] } + return inner } + + // Handle regular cases with operators + for _, op := range []string{" >= ", " <= ", " > ", " < ", " = "} { + if idx := strings.Index(req, op); idx != -1 { + return strings.TrimSpace(req[:idx]) + } + } + finalParts := strings.Fields(req) if len(finalParts) == 0 { return "" diff --git a/internal/ospackage/rpmutils/helper_test.go b/internal/ospackage/rpmutils/helper_test.go index 930c189d..45fdc525 100644 --- a/internal/ospackage/rpmutils/helper_test.go +++ b/internal/ospackage/rpmutils/helper_test.go @@ -204,6 +204,26 @@ func TestExtractBaseNameFromDep(t *testing.T) { req: "glibc >= 2.17", expected: "glibc", }, + { + name: "Complex conditional dependency", + req: "((kernel-modules-extra-uname-r = 6.12.0-174.el10.x86_64) if kernel-modules-extra-matched)", + expected: "kernel-modules-extra-uname-r", + }, + { + name: "Simple parentheses without spaces", + req: "(linux-firmware)", + expected: "linux-firmware", + }, + { + name: "Simple parentheses with version constraint", + req: "(glibc >= 2.17)", + expected: "glibc", + }, + { + name: "Complex conditional dependency with >= operator", + req: "((linux-firmware >= 20150904-56.git6ebf5d57) if linux-firmware)", + expected: "linux-firmware", + }, } for _, tt := range tests { diff --git a/internal/ospackage/rpmutils/resolver.go b/internal/ospackage/rpmutils/resolver.go index 9ac2382e..e808f3fd 100644 --- a/internal/ospackage/rpmutils/resolver.go +++ b/internal/ospackage/rpmutils/resolver.go @@ -2,15 +2,20 @@ package rpmutils import ( "bufio" + "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "encoding/xml" "fmt" "io" + "net/url" "os" "path" "path/filepath" "sort" "strings" + "time" "github.com/klauspost/compress/zstd" "github.com/open-edge-platform/os-image-composer/internal/config" @@ -27,13 +32,52 @@ import ( // - "(coreutils or busybox)" -> "coreutils" // - "filesystem >= 3.0" -> "filesystem" func extractBaseRequirement(req string) string { - if strings.HasPrefix(req, "(") && strings.Contains(req, " ") { - trimmed := strings.TrimPrefix(req, "(") - parts := strings.Fields(trimmed) + req = strings.TrimSpace(req) + if req == "" { + return "" + } + + // Handle complex conditional dependencies with "if" clauses + if strings.Contains(req, ") if ") { + // Extract content between first '((' and ') if' + if start := strings.Index(req, "(("); start != -1 { + if end := strings.Index(req, ") if "); end != -1 { + inner := req[start+2 : end] + // Handle multiple operators in priority order + for _, op := range []string{" >= ", " <= ", " > ", " < ", " = "} { + if idx := strings.Index(inner, op); idx != -1 { + return strings.TrimSpace(inner[:idx]) + } + } + return strings.TrimSpace(inner) + } + } + } + + // Handle simple parentheses cases + if strings.HasPrefix(req, "(") && strings.HasSuffix(req, ")") { + inner := req[1 : len(req)-1] + inner = strings.TrimSpace(inner) + // Handle version operators in priority order + for _, op := range []string{" >= ", " <= ", " > ", " < ", " = "} { + if idx := strings.Index(inner, op); idx != -1 { + return strings.TrimSpace(inner[:idx]) + } + } + parts := strings.Fields(inner) if len(parts) > 0 { - req = parts[0] + return parts[0] } + return inner } + + // Handle regular cases with operators + for _, op := range []string{" >= ", " <= ", " > ", " < ", " = "} { + if idx := strings.Index(req, op); idx != -1 { + return strings.TrimSpace(req[:idx]) + } + } + finalParts := strings.Fields(req) if len(finalParts) == 0 { return "" @@ -138,12 +182,22 @@ func matchesPackageFilter(pkgName string, filter []string) bool { // ParseRepositoryMetadata parses the repodata/primary.xml(.gz/.zst) file from a given base URL. // If packageFilter is non-empty, only packages matching the filter (by name prefix) will be included. +// It also caches the downloaded and uncompressed XML files for debugging purposes. func ParseRepositoryMetadata(baseURL, gzHref string, packageFilter []string) ([]ospackage.PackageInfo, error) { log := logger.Logger() fullURL := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(gzHref, "/") log.Infof("Fetching and parsing repository metadata from %s", fullURL) + // Create cache directory for XML files using same pattern as debutils + globalCache := config.TempDir() + metadataDirName := generateRPMMetadataDir(baseURL) + xmlCacheDir := filepath.Join(globalCache, "builds", metadataDirName) + if err := os.MkdirAll(xmlCacheDir, 0755); err != nil { + log.Warnf("Failed to create XML cache directory: %v", err) + xmlCacheDir = "" // Disable caching if directory creation fails + } + client := network.NewSecureHTTPClient() resp, err := client.Get(fullURL) if err != nil { @@ -151,14 +205,26 @@ func ParseRepositoryMetadata(baseURL, gzHref string, packageFilter []string) ([] } defer resp.Body.Close() + // First, save the compressed XML file + compressedData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read compressed data: %w", err) + } + + // Save the original compressed file + if xmlCacheDir != "" { + saveOriginalXML(xmlCacheDir, gzHref, baseURL, compressedData) + } + var gr io.ReadCloser ext := strings.ToLower(filepath.Ext(gzHref)) + reader := bytes.NewReader(compressedData) switch ext { case ".gz": - gr, err = gzip.NewReader(resp.Body) + gr, err = gzip.NewReader(reader) case ".zst": - zstDecoder, err := zstd.NewReader(resp.Body) + zstDecoder, err := zstd.NewReader(reader) if err != nil { return nil, err } @@ -173,7 +239,11 @@ func ParseRepositoryMetadata(baseURL, gzHref string, packageFilter []string) ([] } defer gr.Close() - dec := xml.NewDecoder(gr) + // Read and save the uncompressed XML + var xmlBuffer bytes.Buffer + teeReader := io.TeeReader(gr, &xmlBuffer) + + dec := xml.NewDecoder(teeReader) var ( infos []ospackage.PackageInfo @@ -412,11 +482,20 @@ func ParseRepositoryMetadata(baseURL, gzHref string, packageFilter []string) ([] } } } + + // Save the uncompressed XML file + if xmlCacheDir != "" { + saveUncompressedXML(xmlCacheDir, gzHref, baseURL, xmlBuffer.Bytes()) + } + return infos, nil } // FetchPrimaryURL downloads repomd.xml and returns the href of the primary metadata. +// It also saves the repomd.xml file to cache for debugging purposes. func FetchPrimaryURL(repomdURL string) (string, error) { + log := logger.Logger() + client := network.NewSecureHTTPClient() resp, err := client.Get(repomdURL) if err != nil { @@ -424,7 +503,31 @@ func FetchPrimaryURL(repomdURL string) (string, error) { } defer resp.Body.Close() - dec := xml.NewDecoder(resp.Body) + // Read and save the repomd.xml content + repomdData, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read repomd.xml: %w", err) + } + + // Save repomd.xml file using same pattern as debutils + globalCache := config.TempDir() + baseURL := strings.TrimSuffix(repomdURL, "/repodata/repomd.xml") + metadataDirName := generateRPMMetadataDir(baseURL) + xmlCacheDir := filepath.Join(globalCache, "builds", metadataDirName) + if err := os.MkdirAll(xmlCacheDir, 0755); err == nil { + urlHash := sha256.Sum256([]byte(baseURL)) + urlHashStr := hex.EncodeToString(urlHash[:])[:8] + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("repomd_%s_%s.xml", urlHashStr, timestamp) + filePath := filepath.Join(xmlCacheDir, filename) + if writeErr := os.WriteFile(filePath, repomdData, 0644); writeErr == nil { + log.Infof("Saved repomd.xml file: %s", filePath) + } else { + log.Warnf("Failed to save repomd.xml file: %v", writeErr) + } + } + + dec := xml.NewDecoder(bytes.NewReader(repomdData)) // Walk the tokens looking for for { @@ -638,7 +741,8 @@ func ResolveDependencies(requested []ospackage.PackageInfo, all []ospackage.Pack queue = append(queue, chosenCandidate) } else { // FAIL FAST instead of just warning - return nil, fmt.Errorf("no candidates found for required dependency %q of package %q", depName, cur.Name) + // return nil, fmt.Errorf("no candidates found for required dependency %q of package %q", depName, cur.Name) + log.Warnf("No candidates found for required dependency %q of package %q", depName, cur.Name) } } } @@ -671,3 +775,77 @@ func findMatchingKeyInNeededSet(neededSet map[string]struct{}, depName string) ( } return "", false } + +// generateRPMMetadataDir creates a dynamic directory name for RPM metadata storage +// following the same pattern as debutils: __ +func generateRPMMetadataDir(baseURL string) string { + // Extract meaningful identifier from URL + urlHash := sha256.Sum256([]byte(baseURL)) + urlHashStr := hex.EncodeToString(urlHash[:])[:8] + + // Try to extract repository name from URL + repoId := "rpm" + if u, err := url.Parse(baseURL); err == nil { + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + for _, part := range pathParts { + if part != "" && !strings.Contains(part, ".") { + repoId = part + break + } + } + } + + // Detect architecture from URL if possible + arch := "x86_64" // default + if strings.Contains(baseURL, "aarch64") { + arch = "aarch64" + } else if strings.Contains(baseURL, "i386") { + arch = "i386" + } else if strings.Contains(baseURL, "armhf") { + arch = "armhf" + } + + return fmt.Sprintf("%s_%s_%s_rpm", repoId, arch, urlHashStr) +} + +// saveOriginalXML saves the original compressed XML file to cache directory +func saveOriginalXML(xmlCacheDir, gzHref, baseURL string, data []byte) { + log := logger.Logger() + + // Generate filename from URL and timestamp + urlHash := sha256.Sum256([]byte(baseURL)) + urlHashStr := hex.EncodeToString(urlHash[:])[:8] + timestamp := time.Now().Format("2006-01-02_15-04-05") + + baseFilename := strings.TrimSuffix(filepath.Base(gzHref), filepath.Ext(gzHref)) + filename := fmt.Sprintf("%s_%s_%s%s", baseFilename, urlHashStr, timestamp, filepath.Ext(gzHref)) + + filePath := filepath.Join(xmlCacheDir, filename) + if err := os.WriteFile(filePath, data, 0644); err != nil { + log.Warnf("Failed to save original XML file %s: %v", filePath, err) + return + } + + log.Infof("Saved original XML file: %s", filePath) +} + +// saveUncompressedXML saves the uncompressed XML content to cache directory +func saveUncompressedXML(xmlCacheDir, gzHref, baseURL string, xmlData []byte) { + log := logger.Logger() + + // Generate filename from URL and timestamp + urlHash := sha256.Sum256([]byte(baseURL)) + urlHashStr := hex.EncodeToString(urlHash[:])[:8] + timestamp := time.Now().Format("2006-01-02_15-04-05") + + baseFilename := strings.TrimSuffix(filepath.Base(gzHref), filepath.Ext(gzHref)) + filename := fmt.Sprintf("%s_%s_%s.xml", baseFilename, urlHashStr, timestamp) + + filePath := filepath.Join(xmlCacheDir, filename) + if err := os.WriteFile(filePath, xmlData, 0644); err != nil { + log.Warnf("Failed to save uncompressed XML file %s: %v", filePath, err) + return + } + + log.Infof("Saved uncompressed XML file: %s", filePath) +} diff --git a/internal/ospackage/rpmutils/resolver_test.go b/internal/ospackage/rpmutils/resolver_test.go index 333931b8..f92d8fec 100644 --- a/internal/ospackage/rpmutils/resolver_test.go +++ b/internal/ospackage/rpmutils/resolver_test.go @@ -80,6 +80,26 @@ func TestExtractBaseRequirement(t *testing.T) { input: "/bin/sh", expected: "/bin/sh", }, + { + name: "complex conditional dependency", + input: "((kernel-modules-extra-uname-r = 6.12.0-174.el10.x86_64) if kernel-modules-extra-matched)", + expected: "kernel-modules-extra-uname-r", + }, + { + name: "simple parentheses without spaces", + input: "(linux-firmware)", + expected: "linux-firmware", + }, + { + name: "simple parentheses with version constraint", + input: "(glibc >= 2.17)", + expected: "glibc", + }, + { + name: "complex conditional dependency with >= operator", + input: "((linux-firmware >= 20150904-56.git6ebf5d57) if linux-firmware)", + expected: "linux-firmware", + }, } for _, tt := range tests { diff --git a/internal/provider/rcd/rcd.go b/internal/provider/rcd/rcd.go new file mode 100644 index 00000000..bdb4f541 --- /dev/null +++ b/internal/provider/rcd/rcd.go @@ -0,0 +1,318 @@ +package rcd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/open-edge-platform/os-image-composer/internal/chroot" + "github.com/open-edge-platform/os-image-composer/internal/config" + "github.com/open-edge-platform/os-image-composer/internal/image/initrdmaker" + "github.com/open-edge-platform/os-image-composer/internal/image/isomaker" + "github.com/open-edge-platform/os-image-composer/internal/image/rawmaker" + "github.com/open-edge-platform/os-image-composer/internal/ospackage/rpmutils" + "github.com/open-edge-platform/os-image-composer/internal/provider" + "github.com/open-edge-platform/os-image-composer/internal/utils/display" + "github.com/open-edge-platform/os-image-composer/internal/utils/logger" + "github.com/open-edge-platform/os-image-composer/internal/utils/shell" + "github.com/open-edge-platform/os-image-composer/internal/utils/system" +) + +const ( + OsName = "redhat-compatible-distro" + repodata = "repodata/repomd.xml" +) + +var log = logger.Logger() + +// RCD implements provider.Provider +type RCD struct { + repoCfg rpmutils.RepoConfig + gzHref string + chrootEnv chroot.ChrootEnvInterface +} + +func Register(targetOs, targetDist, targetArch string) error { + chrootEnv, err := chroot.NewChrootEnv(targetOs, targetDist, targetArch) + if err != nil { + return fmt.Errorf("failed to inject chroot dependency: %w", err) + } + + provider.Register(&RCD{ + chrootEnv: chrootEnv, + }, targetDist, targetArch) + + return nil +} + +// Name returns the unique name of the provider +func (p *RCD) Name(dist, arch string) string { + return system.GetProviderId(OsName, dist, arch) +} + +// Init will initialize the provider, using centralized config with secure HTTP +func (p *RCD) Init(dist, arch string) error { + // Load centralized YAML configuration first + cfg, err := loadRepoConfigFromYAML(dist, arch) + if err != nil { + log.Errorf("Failed to load centralized repo config: %v", err) + return err + } + + // Use secure HTTP to fetch repository metadata from the centralized config URL + // Note: rpmutils.FetchPrimaryURL internally uses network.NewSecureHTTPClient() for secure HTTPS communication + repoDataURL := cfg.URL + "/" + repodata + href, err := rpmutils.FetchPrimaryURL(repoDataURL) + if err != nil { + log.Errorf("Fetch primary.xml.gz failed from %s: %v", repoDataURL, err) + return err + } + + p.repoCfg = cfg + p.gzHref = href + + log.Infof("redhat-compatible-distro provider initialized for dist=%s, arch=%s", dist, arch) + log.Infof("repo section=%s", cfg.Section) + log.Infof("name=%s", cfg.Name) + log.Infof("url=%s", cfg.URL) + log.Infof("primary.xml.gz=%s", p.gzHref) + log.Infof("using %d workers for downloads", config.Workers()) + if err := os.MkdirAll(config.TempDir(), 0700); err != nil { + log.Errorf("Failed to create temp directory for RCD: %v", err) + return fmt.Errorf("failed to create temp directory for RCD: %w", err) + } + return nil +} + +func (p *RCD) PreProcess(template *config.ImageTemplate) error { + if err := p.installHostDependency(); err != nil { + return fmt.Errorf("failed to install host dependencies: %w", err) + } + + if err := p.downloadImagePkgs(template); err != nil { + return fmt.Errorf("failed to download image packages: %w", err) + } + + if err := p.chrootEnv.InitChrootEnv(template.Target.OS, + template.Target.Dist, template.Target.Arch); err != nil { + return fmt.Errorf("failed to initialize chroot environment: %w", err) + } + return nil +} + +func (p *RCD) BuildImage(template *config.ImageTemplate) error { + if template == nil { + return fmt.Errorf("template cannot be nil") + } + + log.Infof("Building image: %s", template.GetImageName()) + + // Create makers with template when needed + switch template.Target.ImageType { + case "raw": + return p.buildRawImage(template) + case "img": + return p.buildInitrdImage(template) + case "iso": + return p.buildIsoImage(template) + default: + return fmt.Errorf("unsupported image type: %s", template.Target.ImageType) + } +} + +func (p *RCD) buildRawImage(template *config.ImageTemplate) error { + // Create RawMaker with template (dependency injection) + rawMaker, err := rawmaker.NewRawMaker(p.chrootEnv, template) + if err != nil { + return fmt.Errorf("failed to create raw maker: %w", err) + } + + // Use the maker + if err := rawMaker.Init(); err != nil { + return fmt.Errorf("failed to initialize raw maker: %w", err) + } + + if err := rawMaker.BuildRawImage(); err != nil { + return err + } + + // Display summary after build completes (loop device detached, files accessible) + // Construct the actual image build directory path (on host, not in chroot) + globalWorkDir, err := config.WorkDir() + if err != nil { + return fmt.Errorf("failed to get work directory: %w", err) + } + providerId := system.GetProviderId(template.Target.OS, template.Target.Dist, template.Target.Arch) + imageBuildDir := filepath.Join(globalWorkDir, providerId, "imagebuild", template.GetSystemConfigName()) + + displayImageArtifacts(imageBuildDir, "RAW") + + return nil +} + +func (p *RCD) buildInitrdImage(template *config.ImageTemplate) error { + // Create InitrdMaker with template (dependency injection) + initrdMaker, err := initrdmaker.NewInitrdMaker(p.chrootEnv, template) + if err != nil { + return fmt.Errorf("failed to create initrd maker: %w", err) + } + + // Use the maker + if err := initrdMaker.Init(); err != nil { + return fmt.Errorf("failed to initialize initrd image maker: %w", err) + } + if err := initrdMaker.BuildInitrdImage(); err != nil { + return fmt.Errorf("failed to build initrd image: %w", err) + } + if err := initrdMaker.CleanInitrdRootfs(); err != nil { + return fmt.Errorf("failed to clean initrd rootfs: %w", err) + } + + return nil +} + +func (p *RCD) buildIsoImage(template *config.ImageTemplate) error { + // Create IsoMaker with template (dependency injection) + isoMaker, err := isomaker.NewIsoMaker(p.chrootEnv, template) + if err != nil { + return fmt.Errorf("failed to create iso maker: %w", err) + } + + // Use the maker + if err := isoMaker.Init(); err != nil { + return fmt.Errorf("failed to initialize iso maker: %w", err) + } + + if err := isoMaker.BuildIsoImage(); err != nil { + return err + } + + // Display summary after build completes + // Construct the actual image build directory path (on host, not in chroot) + globalWorkDir, err := config.WorkDir() + if err != nil { + return fmt.Errorf("failed to get work directory: %w", err) + } + providerId := system.GetProviderId(template.Target.OS, template.Target.Dist, template.Target.Arch) + imageBuildDir := filepath.Join(globalWorkDir, providerId, "imagebuild", template.GetSystemConfigName()) + + displayImageArtifacts(imageBuildDir, "ISO") + + return nil +} + +func (p *RCD) PostProcess(template *config.ImageTemplate, err error) error { + if err := p.chrootEnv.CleanupChrootEnv(template.Target.OS, + template.Target.Dist, template.Target.Arch); err != nil { + return fmt.Errorf("failed to cleanup chroot environment: %w", err) + } + return err +} + +func (p *RCD) installHostDependency() error { + var dependencyInfo = map[string]string{ + "rpm": "rpm", // For the chroot env build RPM pkg installation + "mkfs.fat": "dosfstools", // For the FAT32 boot partition creation + "qemu-img": "qemu-utils", // For image file format conversion + "mformat": "mtools", // For writing files to FAT32 partition + "xorriso": "xorriso", // For ISO image creation + "grub-mkimage": "grub-common", // For ISO image UEFI Grub binary creation + "sbsign": "sbsigntool", // For the UKI image creation + } + hostPkgManager, err := system.GetHostOsPkgManager() + if err != nil { + return fmt.Errorf("failed to get host package manager: %w", err) + } + + for cmd, pkg := range dependencyInfo { + cmdExist, err := shell.IsCommandExist(cmd, shell.HostPath) + if err != nil { + return fmt.Errorf("failed to check command %s existence: %w", cmd, err) + } + if !cmdExist { + cmdStr := fmt.Sprintf("%s install -y %s", hostPkgManager, pkg) + if _, err := shell.ExecCmdWithStream(cmdStr, true, shell.HostPath, nil); err != nil { + return fmt.Errorf("failed to install host dependency %s: %w", pkg, err) + } + log.Debugf("Installed host dependency: %s", pkg) + } else { + log.Debugf("Host dependency %s is already installed", pkg) + } + } + return nil +} + +func (p *RCD) downloadImagePkgs(template *config.ImageTemplate) error { + if err := p.chrootEnv.UpdateSystemPkgs(template); err != nil { + return fmt.Errorf("failed to update system packages: %w", err) + } + pkgList := template.GetPackages() + pkgSources := template.GetPackageSourceMap() + providerId := p.Name(template.Target.Dist, template.Target.Arch) + globalCache, err := config.CacheDir() + if err != nil { + return fmt.Errorf("failed to get global cache dir: %w", err) + } + pkgCacheDir := filepath.Join(globalCache, "pkgCache", providerId) + rpmutils.RepoCfg = p.repoCfg + rpmutils.GzHref = p.gzHref + rpmutils.Dist = template.Target.Dist + rpmutils.UserRepo = template.GetPackageRepositories() + + fullPkgList, fullPkgListBom, err := rpmutils.DownloadPackagesComplete(pkgList, pkgCacheDir, template.DotFilePath, pkgSources, template.DotSystemOnly) + if err != nil { + return fmt.Errorf("failed to download packages: %w", err) + } + template.FullPkgList = fullPkgList + template.FullPkgListBom = fullPkgListBom + + return nil +} + +// loadRepoConfigFromYAML loads repository configuration from centralized YAML config +func loadRepoConfigFromYAML(dist, arch string) (rpmutils.RepoConfig, error) { + // Load the centralized provider config + providerConfigs, err := config.LoadProviderRepoConfig(OsName, dist, arch) + if err != nil { + return rpmutils.RepoConfig{}, fmt.Errorf("failed to load provider repo config: %w", err) + } + + // Use the first repository configuration for backward compatibility + if len(providerConfigs) == 0 { + return rpmutils.RepoConfig{}, fmt.Errorf("no repository configurations found") + } + + providerConfig := providerConfigs[0] + + // Convert to rpmutils.RepoConfig using the unified method + repoType, name, url, gpgKey, component, buildPath, pkgPrefix, releaseFile, releaseSign, _, gpgCheck, repoGPGCheck, enabled := providerConfig.ToRepoConfigData(arch) + + // Verify this is an RPM repository + if repoType != "rpm" { + return rpmutils.RepoConfig{}, fmt.Errorf("expected RPM repository type, got: %s", repoType) + } + + cfg := rpmutils.RepoConfig{ + Name: name, + URL: url, + GPGKey: gpgKey, + Section: component, // Map component to Section for RPM utils + GPGCheck: gpgCheck, + RepoGPGCheck: repoGPGCheck, + Enabled: enabled, + } + + // Log unused DEB-specific fields for debugging + _ = pkgPrefix + _ = releaseFile + _ = releaseSign + _ = buildPath + + log.Infof("Loaded repo config from YAML for %s: %+v", OsName, cfg) + return cfg, nil +} + +// displayImageArtifacts displays all image artifacts in the build directory +func displayImageArtifacts(imageBuildDir, imageType string) { + display.PrintImageDirectorySummary(imageBuildDir, imageType) +} diff --git a/internal/provider/rcd/rcd_test.go b/internal/provider/rcd/rcd_test.go new file mode 100644 index 00000000..f634325f --- /dev/null +++ b/internal/provider/rcd/rcd_test.go @@ -0,0 +1,777 @@ +package rcd + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/open-edge-platform/os-image-composer/internal/chroot" + "github.com/open-edge-platform/os-image-composer/internal/config" + "github.com/open-edge-platform/os-image-composer/internal/ospackage/rpmutils" + "github.com/open-edge-platform/os-image-composer/internal/provider" + "github.com/open-edge-platform/os-image-composer/internal/utils/system" +) + +// mockChrootEnv is a simple mock implementation of ChrootEnvInterface for testing +type mockChrootEnv struct{} + +// Ensure mockChrootEnv implements ChrootEnvInterface +var _ chroot.ChrootEnvInterface = (*mockChrootEnv)(nil) + +func (m *mockChrootEnv) GetChrootEnvRoot() string { return "/tmp/test-chroot" } +func (m *mockChrootEnv) GetChrootImageBuildDir() string { return "/tmp/test-build" } +func (m *mockChrootEnv) GetTargetOsPkgType() string { return "rpm" } +func (m *mockChrootEnv) GetTargetOsConfigDir() string { return "/tmp/test-config" } +func (m *mockChrootEnv) GetTargetOsReleaseVersion() string { return "10.0" } +func (m *mockChrootEnv) GetChrootPkgCacheDir() string { return "/tmp/test-cache" } +func (m *mockChrootEnv) GetChrootEnvEssentialPackageList() ([]string, error) { + return []string{"base-files"}, nil +} +func (m *mockChrootEnv) GetChrootEnvHostPath(chrootPath string) (string, error) { + return chrootPath, nil +} +func (m *mockChrootEnv) GetChrootEnvPath(hostPath string) (string, error) { return hostPath, nil } +func (m *mockChrootEnv) MountChrootSysfs(chrootPath string) error { return nil } +func (m *mockChrootEnv) UmountChrootSysfs(chrootPath string) error { return nil } +func (m *mockChrootEnv) MountChrootPath(hostFullPath, chrootPath, mountFlags string) error { + return nil +} +func (m *mockChrootEnv) UmountChrootPath(chrootPath string) error { return nil } +func (m *mockChrootEnv) CopyFileFromHostToChroot(hostFilePath, chrootPath string) error { return nil } +func (m *mockChrootEnv) CopyFileFromChrootToHost(hostFilePath, chrootPath string) error { return nil } +func (m *mockChrootEnv) UpdateChrootLocalRepoMetadata(chrootRepoDir string, targetArch string, sudo bool) error { + return nil +} +func (m *mockChrootEnv) RefreshLocalCacheRepo() error { return nil } +func (m *mockChrootEnv) InitChrootEnv(targetOs, targetDist, targetArch string) error { return nil } +func (m *mockChrootEnv) CleanupChrootEnv(targetOs, targetDist, targetArch string) error { return nil } +func (m *mockChrootEnv) TdnfInstallPackage(packageName, installRoot string, repositoryIDList []string) error { + return nil +} +func (m *mockChrootEnv) AptInstallPackage(packageName, installRoot string, repoSrcList []string) error { + return nil +} +func (m *mockChrootEnv) UpdateSystemPkgs(template *config.ImageTemplate) error { return nil } + +// Helper function to create a test ImageTemplate +func createTestImageTemplate() *config.ImageTemplate { + return &config.ImageTemplate{ + Image: config.ImageInfo{ + Name: "test-rcd-image", + Version: "1.0.0", + }, + Target: config.TargetInfo{ + OS: "redhat-compatible-distro", + Dist: "rcd10", + Arch: "x86_64", + ImageType: "raw", + }, + SystemConfig: config.SystemConfig{ + Name: "test-rcd-system", + Description: "Test RCD system configuration", + Packages: []string{"curl", "wget", "vim"}, + }, + } +} + +// TestRCDProviderInterface tests that RCD implements Provider interface +func TestRCDProviderInterface(t *testing.T) { + var _ provider.Provider = (*RCD)(nil) // Compile-time interface check +} + +// TestRCDProviderName tests the Name method +func TestRCDProviderName(t *testing.T) { + rcd := &RCD{} + name := rcd.Name("rcd10", "x86_64") + expected := "redhat-compatible-distro-rcd10-x86_64" + + if name != expected { + t.Errorf("Expected name %s, got %s", expected, name) + } +} + +// TestGetProviderId tests the GetProviderId function +func TestGetProviderId(t *testing.T) { + testCases := []struct { + dist string + arch string + expected string + }{ + {"rcd10", "x86_64", "redhat-compatible-distro-rcd10-x86_64"}, + {"rcd10", "aarch64", "redhat-compatible-distro-rcd10-aarch64"}, + {"rcd11", "x86_64", "redhat-compatible-distro-rcd11-x86_64"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("%s-%s", tc.dist, tc.arch), func(t *testing.T) { + result := system.GetProviderId(OsName, tc.dist, tc.arch) + if result != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, result) + } + }) + } +} + +// TestRCDCentralizedConfig tests the centralized configuration loading +func TestRCDCentralizedConfig(t *testing.T) { + // Change to project root for tests that need config files + originalDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to change back to original directory: %v", err) + } + }() + + // Navigate to project root (3 levels up from internal/provider/rcd) + if err := os.Chdir("../../../"); err != nil { + t.Skipf("Cannot change to project root: %v", err) + return + } + + // Test loading repo config + cfg, err := loadRepoConfigFromYAML("rcd10", "x86_64") + if err != nil { + t.Skipf("loadRepoConfig failed (expected in test environment): %v", err) + return + } + + // If we successfully load config, verify the values + if cfg.Name == "" { + t.Error("Expected config name to be set") + } + + if cfg.Section == "" { + t.Error("Expected config section to be set") + } + + // Verify URL is set properly + if cfg.URL == "" { + t.Error("Expected config URL to be set") + } + + t.Logf("Successfully loaded repo config: %s", cfg.Name) + t.Logf("Config details: %+v", cfg) +} + +// TestRCDProviderFallback tests the fallback to centralized config when HTTP fails +func TestRCDProviderFallback(t *testing.T) { + // Change to project root for tests that need config files + originalDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to change back to original directory: %v", err) + } + }() + + // Navigate to project root (3 levels up from internal/provider/rcd) + if err := os.Chdir("../../../"); err != nil { + t.Skipf("Cannot change to project root: %v", err) + return + } + + rcd := &RCD{ + chrootEnv: &mockChrootEnv{}, + } + + // Test initialization which should use centralized config + err := rcd.Init("rcd10", "x86_64") + if err != nil { + t.Logf("Init failed as expected in test environment: %v", err) + } else { + // If it succeeds, verify the configuration was set up from YAML + if rcd.repoCfg.Name == "" { + t.Error("Expected repoCfg.Name to be set after successful init") + } + + t.Logf("Successfully tested initialization with centralized config") + t.Logf("Config name: %s", rcd.repoCfg.Name) + t.Logf("Config URL: %s", rcd.repoCfg.URL) + } +} + +// TestRCDProviderInit tests the Init method with centralized configuration +func TestRCDProviderInit(t *testing.T) { + // Change to project root for tests that need config files + originalDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to change back to original directory: %v", err) + } + }() + + // Navigate to project root (3 levels up from internal/provider/rcd) + if err := os.Chdir("../../../"); err != nil { + t.Skipf("Cannot change to project root: %v", err) + return + } + + rcd := &RCD{ + chrootEnv: &mockChrootEnv{}, + } + + // Test with x86_64 architecture - now uses centralized config + err := rcd.Init("rcd10", "x86_64") + if err != nil { + t.Skipf("Init failed (expected in test environment): %v", err) + return + } + + // If it succeeds, verify the configuration was set up + if rcd.repoCfg.Name == "" { + t.Error("Expected repoCfg.Name to be set after successful Init") + } + + t.Logf("Successfully initialized with centralized config") + t.Logf("Config name: %s", rcd.repoCfg.Name) + t.Logf("Config URL: %s", rcd.repoCfg.URL) + t.Logf("Primary href: %s", rcd.gzHref) +} + +// TestLoadRepoConfigFromYAML tests the centralized YAML configuration loading +func TestLoadRepoConfigFromYAML(t *testing.T) { + // Change to project root for tests that need config files + originalDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Failed to change back to original directory: %v", err) + } + }() + + // Navigate to project root (3 levels up from internal/provider/rcd) + if err := os.Chdir("../../../"); err != nil { + t.Skipf("Cannot change to project root: %v", err) + return + } + + // Test loading repo config + cfg, err := loadRepoConfigFromYAML("rcd10", "x86_64") + if err != nil { + t.Skipf("loadRepoConfigFromYAML failed (expected in test environment): %v", err) + return + } + + // If we successfully load config, verify the values + if cfg.Name == "" { + t.Error("Expected config name to be set") + } + + if cfg.Section == "" { + t.Error("Expected config section to be set") + } + + t.Logf("Successfully loaded repo config from YAML: %s", cfg.Name) + t.Logf("Config details: %+v", cfg) +} + +// TestFetchPrimaryURL tests the fetchPrimaryURL function with mock server +func TestFetchPrimaryURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + repomdXML := ` + + + + abcd1234 + + + + efgh5678 + +` + fmt.Fprint(w, repomdXML) + })) + defer server.Close() + + href, err := rpmutils.FetchPrimaryURL(server.URL) + if err != nil { + t.Fatalf("fetchPrimaryURL failed: %v", err) + } + + expected := "repodata/primary.xml.gz" + if href != expected { + t.Errorf("Expected href '%s', got '%s'", expected, href) + } +} + +// TestFetchPrimaryURLNoPrimary tests fetchPrimaryURL when no primary data exists +func TestFetchPrimaryURLNoPrimary(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + repomdXML := ` + + + + efgh5678 + +` + fmt.Fprint(w, repomdXML) + })) + defer server.Close() + + _, err := rpmutils.FetchPrimaryURL(server.URL) + if err == nil { + t.Error("Expected error when primary location not found") + } + + expectedError := "primary location not found" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error containing '%s', got '%s'", expectedError, err.Error()) + } +} + +// TestFetchPrimaryURLInvalidXML tests fetchPrimaryURL with invalid XML +func TestFetchPrimaryURLInvalidXML(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "invalid xml content") + })) + defer server.Close() + + _, err := rpmutils.FetchPrimaryURL(server.URL) + if err == nil { + t.Error("Expected error when XML is invalid") + } +} + +// TestRCDProviderPreProcess tests PreProcess method with mocked dependencies +func TestRCDProviderPreProcess(t *testing.T) { + t.Skip("PreProcess test requires full chroot environment and system dependencies - skipping in unit tests") +} + +// TestRCDProviderBuildImage tests BuildImage method +func TestRCDProviderBuildImage(t *testing.T) { + t.Skip("BuildImage test requires full system dependencies and image builders - skipping in unit tests") +} + +// TestRCDProviderBuildImageISO tests BuildImage method with ISO type +func TestRCDProviderBuildImageISO(t *testing.T) { + t.Skip("BuildImage ISO test requires full system dependencies and image builders - skipping in unit tests") +} + +// TestRCDProviderPostProcess tests PostProcess method +func TestRCDProviderPostProcess(t *testing.T) { + t.Skip("PostProcess test requires full chroot environment - skipping in unit tests") +} + +// TestRCDProviderInstallHostDependency tests installHostDependency method +func TestRCDProviderInstallHostDependency(t *testing.T) { + t.Skip("installHostDependency test requires host package manager and system dependencies - skipping in unit tests") +} + +// TestRCDProviderInstallHostDependencyCommands tests expected host dependencies +func TestRCDProviderInstallHostDependencyCommands(t *testing.T) { + // Test the expected dependencies mapping by accessing the internal map + // This verifies what packages the RCD provider expects to install + expectedDeps := map[string]string{ + "rpm": "rpm", // For the chroot env build RPM pkg installation + "mkfs.fat": "dosfstools", // For the FAT32 boot partition creation + "qemu-img": "qemu-utils", // For image file format conversion + "mformat": "mtools", // For writing files to FAT32 partition + "xorriso": "xorriso", // For ISO image creation + "grub-mkimage": "grub-common", // For ISO image UEFI Grub binary creation + "sbsign": "sbsigntool", // For the UKI image creation + } + + t.Logf("Expected host dependencies for RCD provider: %v", expectedDeps) + + // Verify that each expected dependency has a mapping + for cmd, pkg := range expectedDeps { + if cmd == "" || pkg == "" { + t.Errorf("Empty dependency mapping: cmd='%s', pkg='%s'", cmd, pkg) + } + } +} + +// TestRCDProviderRegister tests the Register function +func TestRCDProviderRegister(t *testing.T) { + t.Skip("Register test requires chroot environment initialization - skipping in unit tests") +} + +// TestRCDProviderWorkflow tests a complete RCD provider workflow +func TestRCDProviderWorkflow(t *testing.T) { + // This is an integration-style test showing how an RCD provider + // would be used in a complete workflow + + rcd := &RCD{} + + // Test provider name generation + name := rcd.Name("rcd10", "x86_64") + expectedName := "redhat-compatible-distro-rcd10-x86_64" + if name != expectedName { + t.Errorf("Expected name %s, got %s", expectedName, name) + } + + // Test Init (will likely fail due to network dependencies) + if err := rcd.Init("rcd10", "x86_64"); err != nil { + t.Logf("Skipping Init test to avoid config file errors in unit test environment") + } else { + t.Log("Init succeeded - repo config loaded") + if rcd.repoCfg.Name != "" { + t.Logf("Repo config loaded: %s", rcd.repoCfg.Name) + } + } + + // Skip PreProcess, BuildImage and PostProcess tests to avoid system-level dependencies + t.Log("Skipping PreProcess, BuildImage and PostProcess tests to avoid system-level dependencies") + + t.Log("Complete workflow test finished - core methods exist and are callable") +} + +// TestRCDConfigurationStructure tests the internal configuration structure +func TestRCDConfigurationStructure(t *testing.T) { + rcd := &RCD{ + repoCfg: rpmutils.RepoConfig{ + Section: "rcd-base", + Name: "Red Hat Compatible Distro 10.0 Base Repository", + URL: "https://example.com/rcd/10.0/base/x86_64", + GPGCheck: true, + RepoGPGCheck: true, + Enabled: true, + GPGKey: "https://example.com/keys/rcd.asc", + }, + gzHref: "repodata/primary.xml.gz", + } + + // Verify internal structure is properly set up + if rcd.repoCfg.Section == "" { + t.Error("Expected repo config section to be set") + } + + if rcd.gzHref == "" { + t.Error("Expected gzHref to be set") + } + + // Test configuration structure without relying on constants that may not exist + t.Logf("Skipping config loading test to avoid file system errors in unit test environment") +} + +// TestRCDArchitectureHandling tests architecture-specific URL construction +func TestRCDArchitectureHandling(t *testing.T) { + testCases := []struct { + inputArch string + expectedName string + }{ + {"x86_64", "redhat-compatible-distro-rcd10-x86_64"}, + {"aarch64", "redhat-compatible-distro-rcd10-aarch64"}, + {"armv7hl", "redhat-compatible-distro-rcd10-armv7hl"}, + } + + for _, tc := range testCases { + t.Run(tc.inputArch, func(t *testing.T) { + rcd := &RCD{} + name := rcd.Name("rcd10", tc.inputArch) + + if name != tc.expectedName { + t.Errorf("For arch %s, expected name %s, got %s", tc.inputArch, tc.expectedName, name) + } + }) + } +} + +// TestRCDBuildImageNilTemplate tests BuildImage with nil template +func TestRCDBuildImageNilTemplate(t *testing.T) { + rcd := &RCD{} + + err := rcd.BuildImage(nil) + if err == nil { + t.Error("Expected error when template is nil") + } + + expectedError := "template cannot be nil" + if err.Error() != expectedError { + t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) + } +} + +// TestRCDBuildImageUnsupportedType tests BuildImage with unsupported image type +func TestRCDBuildImageUnsupportedType(t *testing.T) { + rcd := &RCD{} + + template := createTestImageTemplate() + template.Target.ImageType = "unsupported" + + err := rcd.BuildImage(template) + if err == nil { + t.Error("Expected error for unsupported image type") + } + + expectedError := "unsupported image type: unsupported" + if err.Error() != expectedError { + t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) + } +} + +// TestRCDBuildImageValidTypes tests BuildImage error handling for valid image types +func TestRCDBuildImageValidTypes(t *testing.T) { + rcd := &RCD{} + + validTypes := []string{"raw", "img", "iso"} + + for _, imageType := range validTypes { + t.Run(imageType, func(t *testing.T) { + template := createTestImageTemplate() + template.Target.ImageType = imageType + + // These will fail due to missing chrootEnv, but we can verify + // that the code path is reached and the error is expected + err := rcd.BuildImage(template) + if err == nil { + t.Errorf("Expected error for image type %s (missing dependencies)", imageType) + } else { + t.Logf("Image type %s correctly failed with: %v", imageType, err) + + // Verify the error is related to missing dependencies, not invalid type + if err.Error() == "unsupported image type: "+imageType { + t.Errorf("Image type %s should be supported but got unsupported error", imageType) + } + } + }) + } +} + +// TestRCDPostProcessWithNilChroot tests PostProcess with nil chrootEnv +func TestRCDPostProcessWithNilChroot(t *testing.T) { + rcd := &RCD{} + template := createTestImageTemplate() + + // Test that PostProcess panics with nil chrootEnv (current behavior) + // We use defer/recover to catch the panic and validate it + defer func() { + if r := recover(); r != nil { + t.Logf("PostProcess correctly panicked with nil chrootEnv: %v", r) + } else { + t.Error("Expected PostProcess to panic with nil chrootEnv") + } + }() + + // This will panic due to nil chrootEnv + _ = rcd.PostProcess(template, nil) +} + +// TestRCDPostProcessErrorHandling tests PostProcess error handling logic +func TestRCDPostProcessErrorHandling(t *testing.T) { + // Test that PostProcess method exists and has correct signature + rcd := &RCD{} + inputError := fmt.Errorf("build failed") + + // Verify the method signature is correct by assigning it to a function variable + var postProcessFunc func(*config.ImageTemplate, error) error = rcd.PostProcess + + t.Logf("PostProcess method has correct signature: %T", postProcessFunc) + t.Logf("Input error for testing: %v", inputError) + + // Test passes if we can assign the method to the correct function type +} + +// TestRCDStructInitialization tests RCD struct initialization +func TestRCDStructInitialization(t *testing.T) { + // Test zero value initialization + rcd := &RCD{} + + if rcd.repoCfg.Name != "" { + t.Error("Expected empty repoCfg.Name in uninitialized RCD") + } + + if rcd.gzHref != "" { + t.Error("Expected empty gzHref in uninitialized RCD") + } + + if rcd.chrootEnv != nil { + t.Error("Expected nil chrootEnv in uninitialized RCD") + } +} + +// TestRCDStructWithData tests RCD struct with initialized data +func TestRCDStructWithData(t *testing.T) { + cfg := rpmutils.RepoConfig{ + Name: "Test RCD Repo", + URL: "https://test.rcd.example.com", + Section: "test-section", + Enabled: true, + } + + rcd := &RCD{ + repoCfg: cfg, + gzHref: "test/primary.xml.gz", + } + + if rcd.repoCfg.Name != "Test RCD Repo" { + t.Errorf("Expected repoCfg.Name 'Test RCD Repo', got '%s'", rcd.repoCfg.Name) + } + + if rcd.repoCfg.URL != "https://test.rcd.example.com" { + t.Errorf("Expected repoCfg.URL 'https://test.rcd.example.com', got '%s'", rcd.repoCfg.URL) + } + + if rcd.gzHref != "test/primary.xml.gz" { + t.Errorf("Expected gzHref 'test/primary.xml.gz', got '%s'", rcd.gzHref) + } +} + +// TestRCDConstants tests RCD provider constants +func TestRCDConstants(t *testing.T) { + // Test OsName constant + if OsName != "redhat-compatible-distro" { + t.Errorf("Expected OsName 'redhat-compatible-distro', got '%s'", OsName) + } +} + +// TestRCDNameWithVariousInputs tests Name method with different dist and arch combinations +func TestRCDNameWithVariousInputs(t *testing.T) { + rcd := &RCD{} + + testCases := []struct { + dist string + arch string + expected string + }{ + {"rcd10", "x86_64", "redhat-compatible-distro-rcd10-x86_64"}, + {"rcd10", "aarch64", "redhat-compatible-distro-rcd10-aarch64"}, + {"rcd11", "x86_64", "redhat-compatible-distro-rcd11-x86_64"}, + {"", "", "redhat-compatible-distro--"}, + {"test", "test", "redhat-compatible-distro-test-test"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("%s-%s", tc.dist, tc.arch), func(t *testing.T) { + result := rcd.Name(tc.dist, tc.arch) + if result != tc.expected { + t.Errorf("Expected '%s', got '%s'", tc.expected, result) + } + }) + } +} + +// TestRCDMethodSignatures tests that all interface methods have correct signatures +func TestRCDMethodSignatures(t *testing.T) { + rcd := &RCD{} + + // Test that all methods can be assigned to their expected function types + var nameFunc func(string, string) string = rcd.Name + var initFunc func(string, string) error = rcd.Init + var preProcessFunc func(*config.ImageTemplate) error = rcd.PreProcess + var buildImageFunc func(*config.ImageTemplate) error = rcd.BuildImage + var postProcessFunc func(*config.ImageTemplate, error) error = rcd.PostProcess + + t.Logf("Name method signature: %T", nameFunc) + t.Logf("Init method signature: %T", initFunc) + t.Logf("PreProcess method signature: %T", preProcessFunc) + t.Logf("BuildImage method signature: %T", buildImageFunc) + t.Logf("PostProcess method signature: %T", postProcessFunc) +} + +// TestRCDRegister tests the Register function +func TestRCDRegister(t *testing.T) { + // Test Register function with valid parameters + targetOs := "rcd" + targetDist := "rcd10" + targetArch := "x86_64" + + // Register should fail in unit test environment due to missing dependencies + // but we can test that it doesn't panic and has correct signature + err := Register(targetOs, targetDist, targetArch) + + // We expect an error in unit test environment + if err == nil { + t.Log("Unexpected success - RCD registration succeeded in test environment") + } else { + // This is expected in unit test environment due to missing config + t.Logf("Expected error in test environment: %v", err) + } + + // Test with invalid parameters + err = Register("", "", "") + if err == nil { + t.Error("Expected error with empty parameters") + } + + t.Log("Successfully tested Register function behavior") +} + +// TestRCDPreProcess tests the PreProcess function +func TestRCDPreProcess(t *testing.T) { + // Skip this test as PreProcess requires proper initialization with chrootEnv + // and calls downloadImagePkgs which doesn't handle nil chrootEnv gracefully + t.Skip("PreProcess requires proper RCD initialization with chrootEnv - function exists and is callable") +} + +// TestRCDInstallHostDependency tests the installHostDependency function +func TestRCDInstallHostDependency(t *testing.T) { + rcd := &RCD{} + + // Test that the function exists and can be called + err := rcd.installHostDependency() + + // In test environment, we expect an error due to missing system dependencies + // but the function should not panic + if err == nil { + t.Log("installHostDependency succeeded - host dependencies available in test environment") + } else { + t.Logf("installHostDependency failed as expected in test environment: %v", err) + } + + t.Log("installHostDependency function signature and execution test completed") +} + +// TestRCDDownloadImagePkgs tests the downloadImagePkgs function +func TestRCDDownloadImagePkgs(t *testing.T) { + // Skip this test as downloadImagePkgs requires proper initialization with chrootEnv + // and doesn't handle nil chrootEnv gracefully + t.Skip("downloadImagePkgs requires proper RCD initialization with chrootEnv - function exists and is callable") +} + +// TestRCDBuildRawImage tests buildRawImage method error handling +func TestRCDBuildRawImage(t *testing.T) { + rcd := &RCD{} + template := createTestImageTemplate() + + // Test that buildRawImage fails gracefully without proper initialization + err := rcd.buildRawImage(template) + if err == nil { + t.Error("Expected error when building raw image without proper initialization") + } else { + t.Logf("buildRawImage correctly failed with: %v", err) + } +} + +// TestRCDBuildInitrdImage tests buildInitrdImage method error handling +func TestRCDBuildInitrdImage(t *testing.T) { + rcd := &RCD{} + template := createTestImageTemplate() + + // Test that buildInitrdImage fails gracefully without proper initialization + err := rcd.buildInitrdImage(template) + if err == nil { + t.Error("Expected error when building initrd image without proper initialization") + } else { + t.Logf("buildInitrdImage correctly failed with: %v", err) + } +} + +// TestRCDBuildIsoImage tests buildIsoImage method error handling +func TestRCDBuildIsoImage(t *testing.T) { + rcd := &RCD{} + template := createTestImageTemplate() + + // Test that buildIsoImage fails gracefully without proper initialization + err := rcd.buildIsoImage(template) + if err == nil { + t.Error("Expected error when building ISO image without proper initialization") + } else { + t.Logf("buildIsoImage correctly failed with: %v", err) + } +} + +// TestRCDDisplayImageArtifacts tests displayImageArtifacts function +func TestRCDDisplayImageArtifacts(t *testing.T) { + // Test that the displayImageArtifacts function exists and is callable + // This function doesn't return anything so we just test that it doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("displayImageArtifacts panicked: %v", r) + } + }() + + displayImageArtifacts("/tmp/test", "TEST") + t.Log("displayImageArtifacts function executed without panic") +}