diff --git a/e2e/build_uki_test.go b/e2e/build_uki_test.go index c5cd36d..22788fb 100644 --- a/e2e/build_uki_test.go +++ b/e2e/build_uki_test.go @@ -133,9 +133,11 @@ func runCommandInIso(auroraboot *Auroraboot, isoFile, command string) string { set -e mkdir -p /tmp/iso /tmp/efi mount -v -o loop %[1]s /tmp/iso 2>&1 > /dev/null +sleep 2 mount -v -o loop /tmp/iso/efiboot.img /tmp/efi 2>&1 > /dev/null %[2]s umount /tmp/efi 2>&1 > /dev/null +sleep 2 umount /tmp/iso 2>&1 > /dev/null `, isoFile, command)) Expect(err).ToNot(HaveOccurred(), out) diff --git a/go.mod b/go.mod index 350fa4d..74fe849 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,14 @@ godebug x509negativeserial=1 require ( github.com/cavaliergopher/grab/v3 v3.0.1 + github.com/containerd/containerd v1.7.23 github.com/distribution/reference v0.6.0 github.com/foxboron/go-uefi v0.0.0-20241017190036-fab4fdf2f2f3 github.com/foxboron/sbctl v0.0.0-20240526163235-64e649b31c8e + github.com/gofrs/uuid v4.4.0+incompatible + github.com/google/go-containerregistry v0.20.2 github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 - github.com/kairos-io/enki v0.2.2 github.com/kairos-io/go-ukify v0.2.5 github.com/kairos-io/kairos-agent/v2 v2.15.4 github.com/kairos-io/kairos-sdk v0.6.1 @@ -23,8 +25,12 @@ require ( github.com/onsi/ginkgo/v2 v2.21.0 github.com/onsi/gomega v1.35.1 github.com/otiai10/copy v1.14.0 + github.com/sanity-io/litter v1.5.5 github.com/spectrocloud-labs/herd v0.4.2 github.com/spectrocloud/peg v0.0.0-20240405075800-c5da7125e30f + github.com/spf13/viper v1.19.0 + github.com/twpayne/go-vfs/v4 v4.3.0 + github.com/twpayne/go-vfs/v5 v5.0.4 github.com/u-root/u-root v0.14.0 github.com/urfave/cli/v2 v2.27.5 golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c @@ -47,13 +53,13 @@ require ( github.com/StackExchange/wmi v1.2.1 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 // indirect + github.com/benbjohnson/clock v1.3.0 // indirect github.com/bramvdbogaerde/go-scp v1.2.0 // indirect github.com/cavaliergopher/grab v2.0.0+incompatible // indirect github.com/cloudflare/circl v1.3.9 // indirect github.com/codingsince1985/checksum v1.2.4 // indirect github.com/containerd/cgroups/v3 v3.0.3 // indirect github.com/containerd/console v1.0.4 // indirect - github.com/containerd/containerd v1.7.23 // indirect github.com/containerd/continuity v0.4.4 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -89,13 +95,11 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gofrs/flock v0.8.1 // indirect - github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/certificate-transparency-go v1.1.2 // indirect github.com/google/go-attestation v0.5.1 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/go-containerregistry v0.20.2 // indirect github.com/google/go-tpm v0.9.1 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect @@ -156,7 +160,6 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/samber/lo v1.38.1 // indirect - github.com/sanity-io/litter v1.5.5 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d // indirect @@ -170,15 +173,12 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/spf13/viper v1.19.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggest/jsonschema-go v0.3.62 // indirect github.com/swaggest/refl v1.3.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/tredoe/osutil v1.5.0 // indirect - github.com/twpayne/go-vfs/v4 v4.3.0 // indirect - github.com/twpayne/go-vfs/v5 v5.0.4 // indirect github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/vbatts/tar-split v0.11.3 // indirect diff --git a/go.sum b/go.sum index 2d2aeec..545d8ed 100644 --- a/go.sum +++ b/go.sum @@ -649,10 +649,6 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kairos-io/enki v0.2.2 h1:p2KtQtyE7v9wHwrACNUs1l+uiOjhY9MNYLQWuQppg/0= -github.com/kairos-io/enki v0.2.2/go.mod h1:IwIUxCd91CLpJLjlpAK9o8X5Ftubfr2yTBd8rL+72kA= -github.com/kairos-io/go-ukify v0.2.4 h1:1rYfPl9ODChePL3g/TC3SYG66mI45RWQGd3GYlzx9Nk= -github.com/kairos-io/go-ukify v0.2.4/go.mod h1:xGAR9EDOo459L7g8AixVaZQHi9ctGy6/Qk44nXJ1TVw= github.com/kairos-io/go-ukify v0.2.5 h1:3ohFO1FhYKbeB/NSsgaq/05CHv1F+/SX1PGDNexL7is= github.com/kairos-io/go-ukify v0.2.5/go.mod h1:xGAR9EDOo459L7g8AixVaZQHi9ctGy6/Qk44nXJ1TVw= github.com/kairos-io/kairos-agent/v2 v2.15.4 h1:hAsFEamXuoV8IYg5MSX3PE0wK4OHmlb94daF1Mrjwhc= diff --git a/internal/cmd/build-uki.go b/internal/cmd/build-uki.go index 7bace8b..d4ae2ac 100644 --- a/internal/cmd/build-uki.go +++ b/internal/cmd/build-uki.go @@ -13,9 +13,9 @@ import ( "sort" "strings" - enkiconfig "github.com/kairos-io/enki/pkg/config" - enkiconstants "github.com/kairos-io/enki/pkg/constants" - enkiutils "github.com/kairos-io/enki/pkg/utils" + "github.com/kairos-io/AuroraBoot/pkg/constants" + "github.com/kairos-io/AuroraBoot/pkg/ops" + "github.com/kairos-io/AuroraBoot/pkg/utils" "github.com/kairos-io/go-ukify/pkg/uki" "github.com/kairos-io/kairos-agent/v2/pkg/elemental" v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" @@ -63,8 +63,8 @@ var BuildUKICmd = cli.Command{ &cli.StringFlag{ Name: "output-type", Aliases: []string{"t"}, - Value: string(enkiconstants.DefaultOutput), - Usage: fmt.Sprintf("Artifact output type [%s]", strings.Join(enkiconstants.OutPutTypes(), ", ")), + Value: string(constants.DefaultOutput), + Usage: fmt.Sprintf("Artifact output type [%s]", strings.Join(constants.OutPutTypes(), ", ")), }, &cli.StringFlag{ Name: "overlay-rootfs", @@ -141,7 +141,7 @@ var BuildUKICmd = cli.Command{ } artifact := ctx.String("output-type") - if artifact != string(enkiconstants.DefaultOutput) && artifact != string(enkiconstants.IsoOutput) && artifact != string(enkiconstants.ContainerOutput) { + if artifact != string(constants.DefaultOutput) && artifact != string(constants.IsoOutput) && artifact != string(constants.ContainerOutput) { return fmt.Errorf("invalid output type: %s", artifact) } @@ -165,7 +165,7 @@ var BuildUKICmd = cli.Command{ } // Check if we are setting a different artifact and overlay-iso is set - if artifact != string(enkiconstants.IsoOutput) { + if artifact != string(constants.IsoOutput) { return fmt.Errorf("overlay-iso is only supported for iso artifacts") } } @@ -199,9 +199,9 @@ var BuildUKICmd = cli.Command{ logger := sdkTypes.NewKairosLogger("auroraboot", logLevel, false) // TODO: Get rid of "configs". - config := enkiconfig.NewConfig( - enkiconfig.WithImageExtractor(v1.OCIImageExtractor{}), - enkiconfig.WithLogger(logger), + config := ops.NewConfig( + ops.WithImageExtractor(v1.OCIImageExtractor{}), + ops.WithLogger(logger), ) if err := checkBuildUKIDeps(config.Arch); err != nil { @@ -210,7 +210,7 @@ var BuildUKICmd = cli.Command{ // artifactsTempDir Is where we copy the kernel and initramfs files // So only artifacts that are needed to build the efi, so we dont pollute the sourceDir - artifactsTempDir, err := os.MkdirTemp("", "enki-build-uki-artifacts-") + artifactsTempDir, err := os.MkdirTemp("", "auroraboot-build-uki-artifacts-") if err != nil { return err } @@ -222,7 +222,7 @@ var BuildUKICmd = cli.Command{ // lets not pollute it // TODO: if img is a dir, we should not copy or rsync anything and just use that dir as source? - sourceDir, err := os.MkdirTemp("", "enki-build-uki-") + sourceDir, err := os.MkdirTemp("", "auroraboot-build-uki-") if err != nil { return err } @@ -305,12 +305,12 @@ var BuildUKICmd = cli.Command{ // Get systemd-boot info (we can sign it at the same time) var systemdBoot string var outputSystemdBootEfi string - if enkiutils.IsAmd64(config.Arch) { - systemdBoot = enkiconstants.UkiSystemdBootx86 - outputSystemdBootEfi = enkiconstants.EfiFallbackNamex86 - } else if enkiutils.IsArm64(config.Arch) { - systemdBoot = enkiconstants.UkiSystemdBootArm - outputSystemdBootEfi = enkiconstants.EfiFallbackNameArm + if utils.IsAmd64(config.Arch) { + systemdBoot = constants.UkiSystemdBootx86 + outputSystemdBootEfi = constants.EfiFallbackNamex86 + } else if utils.IsArm64(config.Arch) { + systemdBoot = constants.UkiSystemdBootArm + outputSystemdBootEfi = constants.EfiFallbackNameArm } else { return fmt.Errorf("unsupported arch: %s", config.Arch) } @@ -354,7 +354,7 @@ var BuildUKICmd = cli.Command{ } switch ctx.String("output-type") { - case string(enkiconstants.IsoOutput): + case string(constants.IsoOutput): absolutePath, err := filepath.Abs(ctx.String("overlay-iso")) if err != nil { return fmt.Errorf("converting overlay-iso to absolute path: %w", err) @@ -362,7 +362,7 @@ var BuildUKICmd = cli.Command{ if err := createISO(e, sourceDir, ctx.String("output-dir"), absolutePath, ctx.String("keys"), kairosVersion, ctx.String("name"), entries, logger); err != nil { return err } - case string(enkiconstants.ContainerOutput): + case string(constants.ContainerOutput): // First create the files if err := createArtifact(sourceDir, ctx.String("output-dir"), ctx.String("keys"), entries, logger); err != nil { return err @@ -376,7 +376,7 @@ var BuildUKICmd = cli.Command{ if err := removeUkiFiles(ctx.String("output-dir"), ctx.String("keys"), entries); err != nil { return err } - case string(enkiconstants.DefaultOutput): + case string(constants.DefaultOutput): if err := createArtifact(sourceDir, ctx.String("output-dir"), ctx.String("keys"), entries, logger); err != nil { return err } @@ -420,15 +420,15 @@ func checkBuildUKIDeps(arch string) error { } func getEfiNeededFiles(arch string) ([]string, error) { - if enkiutils.IsAmd64(arch) { + if utils.IsAmd64(arch) { return []string{ - enkiconstants.UkiSystemdBootStubx86, - enkiconstants.UkiSystemdBootx86, + constants.UkiSystemdBootStubx86, + constants.UkiSystemdBootx86, }, nil - } else if enkiutils.IsArm64(arch) { + } else if utils.IsArm64(arch) { return []string{ - enkiconstants.UkiSystemdBootStubArm, - enkiconstants.UkiSystemdBootArm, + constants.UkiSystemdBootStubArm, + constants.UkiSystemdBootArm, }, nil } else { return nil, fmt.Errorf("unsupported arch: %s", arch) @@ -622,10 +622,10 @@ func ZstdFile(sourcePath, targetPath string) error { } func getEfiStub(arch string) (string, error) { - if enkiutils.IsAmd64(arch) { - return enkiconstants.UkiSystemdBootStubx86, nil - } else if enkiutils.IsArm64(arch) { - return enkiconstants.UkiSystemdBootStubArm, nil + if utils.IsAmd64(arch) { + return constants.UkiSystemdBootStubx86, nil + } else if utils.IsArm64(arch) { + return constants.UkiSystemdBootStubArm, nil } else { return "", nil } @@ -635,9 +635,9 @@ func createConfFiles(sourceDir, cmdline, title, finalEfiName, version string, in // This is stored in the config var extraCmdline string // For the config title we get only the extra cmdline we added, no replacement of spaces with underscores needed - extraCmdline = strings.TrimSpace(strings.TrimPrefix(cmdline, enkiconstants.UkiCmdline)) + extraCmdline = strings.TrimSpace(strings.TrimPrefix(cmdline, constants.UkiCmdline)) // For the default install entry, do not add anything on the config - if extraCmdline == enkiconstants.UkiCmdlineInstall { + if extraCmdline == constants.UkiCmdlineInstall { extraCmdline = "" } @@ -675,7 +675,7 @@ func createSystemdConf(dir, defaultEntry, secureBootEnroll string) error { } else { // Get the generic efi file that we produce from the default cmdline // This is the one name that has nothing added, just the version - finalEfiConf = NameFromCmdline(enkiconstants.ArtifactBaseName, enkiconstants.UkiCmdline+" "+enkiconstants.UkiCmdlineInstall) + ".conf" + finalEfiConf = NameFromCmdline(constants.ArtifactBaseName, constants.UkiCmdline+" "+constants.UkiCmdlineInstall) + ".conf" } // Set that as default selection for booting @@ -687,9 +687,9 @@ func createSystemdConf(dir, defaultEntry, secureBootEnroll string) error { return nil } -func createISO(e *elemental.Elemental, sourceDir, outputDir, overlayISO, keysDir, kairosVersion, artifactName string, entries []enkiutils.BootEntry, logger sdkTypes.KairosLogger) error { +func createISO(e *elemental.Elemental, sourceDir, outputDir, overlayISO, keysDir, kairosVersion, artifactName string, entries []utils.BootEntry, logger sdkTypes.KairosLogger) error { // isoDir is where we generate the img file. We pass this dir to xorriso. - isoDir, err := os.MkdirTemp("", "enki-iso-dir-") + isoDir, err := os.MkdirTemp("", "auroraboot-iso-dir-") if err != nil { return err } @@ -758,7 +758,7 @@ func createISO(e *elemental.Elemental, sourceDir, outputDir, overlayISO, keysDir return nil } -func imageFiles(sourceDir, keysDir string, entries []enkiutils.BootEntry) (map[string][]string, error) { +func imageFiles(sourceDir, keysDir string, entries []utils.BootEntry) (map[string][]string, error) { // the keys are the target dirs // the values are the source files that should be copied into the target dir data := map[string][]string{ @@ -855,7 +855,7 @@ func copyFilesToImg(imgFile string, filesMap map[string][]string) error { // Create artifact just outputs the files from the sourceDir to the outputDir // Maintains the same structure as the sourceDir which is the final structure we want -func createArtifact(sourceDir, outputDir, keysDir string, entries []enkiutils.BootEntry, logger sdkTypes.KairosLogger) error { +func createArtifact(sourceDir, outputDir, keysDir string, entries []utils.BootEntry, logger sdkTypes.KairosLogger) error { filesMap, err := imageFiles(sourceDir, keysDir, entries) if err != nil { return err @@ -908,7 +908,7 @@ func createContainer(sourceDir, outputDir, artifactName, version string, logger return err } // Create tarball from sourceDir - err = enkiutils.Tar(sourceDir, temp) + err = utils.Tar(sourceDir, temp) if err != nil { return err } @@ -923,7 +923,7 @@ func createContainer(sourceDir, outputDir, artifactName, version string, logger if artifactName != "" { tarName = fmt.Sprintf("%s.tar", artifactName) } - err = enkiutils.CreateTar(logger, temp.Name(), finalImage, tarName, arch, os) + err = utils.CreateTar(logger, temp.Name(), finalImage, tarName, arch, os) if err != nil { return err } @@ -933,7 +933,7 @@ func createContainer(sourceDir, outputDir, artifactName, version string, logger // removeUkiFiles removes all the files and directories inside the output directory that match our filesMap // so this should only remove the generated intermediate artifacts that we use to build the container -func removeUkiFiles(outputDir, keysDir string, entries []enkiutils.BootEntry) error { +func removeUkiFiles(outputDir, keysDir string, entries []utils.BootEntry) error { filesMap, _ := imageFiles(outputDir, keysDir, entries) for dir, files := range filesMap { for _, f := range files { @@ -957,33 +957,33 @@ func removeUkiFiles(outputDir, keysDir string, entries []enkiutils.BootEntry) er // For each cmdline passed, we generate a uki file with that cmdline // extend-cmdline will just extend the default cmdline so we only create one efi file // extra-cmdline will create a new efi file for each cmdline passed -func GetUkiCmdline(cmdlineExtend, bootBranding string, extraCmdlines []string) []enkiutils.BootEntry { - defaultCmdLine := enkiconstants.UkiCmdline + " " + enkiconstants.UkiCmdlineInstall +func GetUkiCmdline(cmdlineExtend, bootBranding string, extraCmdlines []string) []utils.BootEntry { + defaultCmdLine := constants.UkiCmdline + " " + constants.UkiCmdlineInstall // Extend only if cmdlineExtend != "" { cmdline := defaultCmdLine + " " + cmdlineExtend - return []enkiutils.BootEntry{{ + return []utils.BootEntry{{ Cmdline: cmdline, Title: bootBranding, - FileName: NameFromCmdline(enkiconstants.ArtifactBaseName, cmdline), + FileName: NameFromCmdline(constants.ArtifactBaseName, cmdline), }} } // default entry - result := []enkiutils.BootEntry{{ + result := []utils.BootEntry{{ Cmdline: defaultCmdLine, Title: bootBranding, - FileName: NameFromCmdline(enkiconstants.ArtifactBaseName, defaultCmdLine), + FileName: NameFromCmdline(constants.ArtifactBaseName, defaultCmdLine), }} // extra for _, extra := range extraCmdlines { cmdline := defaultCmdLine + " " + extra - result = append(result, enkiutils.BootEntry{ + result = append(result, utils.BootEntry{ Cmdline: cmdline, Title: bootBranding, - FileName: NameFromCmdline(enkiconstants.ArtifactBaseName, cmdline), + FileName: NameFromCmdline(constants.ArtifactBaseName, cmdline), }) } @@ -991,13 +991,13 @@ func GetUkiCmdline(cmdlineExtend, bootBranding string, extraCmdlines []string) [ } // GetUkiSingleCmdlines returns the single-efi-cmdline as passed by the user. -func GetUkiSingleCmdlines(bootBranding string, cmdlines []string, logger sdkTypes.KairosLogger) []enkiutils.BootEntry { - result := []enkiutils.BootEntry{} +func GetUkiSingleCmdlines(bootBranding string, cmdlines []string, logger sdkTypes.KairosLogger) []utils.BootEntry { + result := []utils.BootEntry{} // extra - defaultCmdLine := enkiconstants.UkiCmdline + " " + enkiconstants.UkiCmdlineInstall + defaultCmdLine := constants.UkiCmdline + " " + constants.UkiCmdlineInstall for _, userValue := range cmdlines { - bootEntry := enkiutils.BootEntry{} + bootEntry := utils.BootEntry{} before, after, hasTitle := strings.Cut(userValue, ":") if hasTitle { @@ -1031,9 +1031,9 @@ func GetUkiSingleCmdlines(bootBranding string, cmdlines []string, logger sdkType // but it can easily be used to identify the efi file and the conf file. // All names are returns in lowercase because FAT doesn't handle case in a predictable way. func NameFromCmdline(basename, cmdline string) string { - cmdlineForEfi := strings.TrimSpace(strings.TrimPrefix(cmdline, enkiconstants.UkiCmdline)) + cmdlineForEfi := strings.TrimSpace(strings.TrimPrefix(cmdline, constants.UkiCmdline)) // For the default install entry, do not add anything on the efi name - if cmdlineForEfi == enkiconstants.UkiCmdlineInstall { + if cmdlineForEfi == constants.UkiCmdlineInstall { cmdlineForEfi = "" } // Although only slashes are truly forbidden, we also replace other characters, diff --git a/pkg/constants/boot_hybrid.img b/pkg/constants/boot_hybrid.img new file mode 100644 index 0000000..d3c6340 Binary files /dev/null and b/pkg/constants/boot_hybrid.img differ diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..09d42e2 --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,122 @@ +package constants + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" +) + +// Eltorito image is basically a grub cdboot.img + grub core image +// So basically you build a grub image with modules embedded and prepend it with the cdboot, which allows it to boot from +// CDRom and run the grub embedded in the image directly from BIOS +// This can be generated from any distro by runing something like: +/* +grub2-mkimage -O i386-pc -o core.img -p /boot/grub2 -d PATH_TO_i386_MODULES ext2 iso9660 linux echo configfile search_label search_fs_file search search_fs_uuid ls normal gzio gettext font gfxterm gfxmenu all_video test true loadenv part_gpt part_msdos biosdisk vga vbe chain boot +cat $(find / -name cdboot.img -print) core.img > eltorito.img + +Important things in the grub image creation: + - -O i386-pc is the architecture we want to build the image for. Bios is i386 + - -p is the prefix dir, this is where grub will start searching for things, including the grub.cfg config, when it boots + - -d is the current dir where modules and images are. Usually this is automatically set so it can be dropped + - the list at the end are the modules to bundle for grub. Honestly the list is not too big and it can probably be dropped to like half for the livecd + as it only uses linux, echo, font, video ones and boot. But it doesnt hurt to have extra modules. +*/ +//go:embed eltorito.img +var Eltorito []byte + +// BootHybrid is boot_hybrid.img which comes bundled with grub +// Its ASM to boot from the grub image embedded +// You can check its source here: https://github.com/rhboot/grub2/blob/fedora-39/grub-core/boot/i386/pc/cdboot.S +// +//go:embed boot_hybrid.img +var BootHybrid []byte + +// GrubLiveBiosCfg is the livecd config for BIOS boot +// +//go:embed grub_live_bios.cfg +var GrubLiveBiosCfg []byte + +type UkiOutput string + +const ( + IsoEFIPath = "/boot/uefi.img" + EfiBootPath = "/EFI/BOOT" + EfiLabel = "COS_GRUB" + EfiFs = "vfat" + IsoRootFile = "rootfs.squashfs" + ISOLabel = "COS_LIVE" + ShimEfiDest = EfiBootPath + "/bootx64.efi" + ShimEfiArmDest = EfiBootPath + "/bootaa64.efi" + BuildImgName = "elemental" + GrubCfg = "grub.cfg" + GrubPrefixDir = "/boot/grub2" + GrubEfiCfg = "search --no-floppy --file --set=root " + IsoKernelPath + + "\nset prefix=($root)" + GrubPrefixDir + + "\nconfigfile $prefix/" + GrubCfg + + IsoBootCatalog = "/boot/boot.catalog" + IsoHybridMBR = "/boot/boot_hybrid.img" + IsoBootFile = "/boot/eltorito.img" + + // These paths are arbitrary but coupled to grub.cfg + IsoKernelPath = "/boot/kernel" + IsoInitrdPath = "/boot/initrd" + + // Default directory and file fileModes + DirPerm = os.ModeDir | os.ModePerm + FilePerm = 0666 + NoWriteDirPerm = 0555 | os.ModeDir + TempDirPerm = os.ModePerm | os.ModeSticky | os.ModeDir + + ArchArm64 = "arm64" + Archx86 = "x86_64" + ArchAmd64 = "amd64" + Archaarch64 = "aarch64" + + UkiCmdline = "console=ttyS0 console=tty1 net.ifnames=1 rd.immucore.oemlabel=COS_OEM rd.immucore.oemtimeout=2 rd.immucore.uki selinux=0 panic=5 rd.shell=0 systemd.crash_reboot=yes" + UkiCmdlineInstall = "install-mode" + UkiSystemdBootx86 = "/usr/kairos/systemd-bootx64.efi" + UkiSystemdBootArm = "/usr/kairos/systemd-bootaa64.efi" + UkiSystemdBootStubx86 = "/usr/kairos/linuxx64.efi.stub" + UkiSystemdBootStubArm = "/usr/kairos/linuxaa64.efi.stub" + + EfiFallbackNamex86 = "BOOTX64.EFI" + EfiFallbackNameArm = "BOOTAA64.EFI" + + ArtifactBaseName = "norole" +) + +const IsoOutput UkiOutput = "iso" +const ContainerOutput UkiOutput = "container" +const DefaultOutput UkiOutput = "uki" + +func OutPutTypes() []string { + return []string{string(IsoOutput), string(ContainerOutput), string(DefaultOutput)} +} + +func GetXorrisoBooloaderArgs(root string) []string { + args := []string{ + "-boot_image", "grub", fmt.Sprintf("bin_path=%s", IsoBootFile), + "-boot_image", "grub", fmt.Sprintf("grub2_mbr=%s/%s", root, IsoHybridMBR), + "-boot_image", "grub", "grub2_boot_info=on", + "-boot_image", "any", "partition_offset=16", + "-boot_image", "any", fmt.Sprintf("cat_path=%s", IsoBootCatalog), + "-boot_image", "any", "cat_hidden=on", + "-boot_image", "any", "boot_info_table=on", + "-boot_image", "any", "platform_id=0x00", + "-boot_image", "any", "emul_type=no_emulation", + "-boot_image", "any", "load_size=2048", + "-append_partition", "2", "0xef", filepath.Join(root, IsoEFIPath), + "-boot_image", "any", "next", + "-boot_image", "any", "efi_path=--interval:appended_partition_2:all::", + "-boot_image", "any", "platform_id=0xef", + "-boot_image", "any", "emul_type=no_emulation", + } + return args +} + +// GetDefaultSquashfsOptions returns the default options to use when creating a squashfs +func GetDefaultSquashfsOptions() []string { + return []string{"-b", "1024k"} +} diff --git a/pkg/constants/eltorito.img b/pkg/constants/eltorito.img new file mode 100644 index 0000000..fa17ea6 Binary files /dev/null and b/pkg/constants/eltorito.img differ diff --git a/pkg/constants/grub_live_bios.cfg b/pkg/constants/grub_live_bios.cfg new file mode 100644 index 0000000..b69af0b --- /dev/null +++ b/pkg/constants/grub_live_bios.cfg @@ -0,0 +1,58 @@ + +search --file --set=root /boot/kernel +set default=0 +set timeout=10 +set timeout_style=menu + +set font=($root)/boot/${grub_cpu}/loader/grub2/fonts/unicode.pf2 +if [ -f ${font} ];then + loadfont ${font} +fi +menuentry "Kairos" --class os --unrestricted { + echo Loading kernel... + linux ($root)/boot/kernel cdroot root=live:CDLABEL=COS_LIVE rd.live.dir=/ rd.live.squashimg=rootfs.squashfs net.ifnames=1 console=ttyS0 console=tty1 rd.cos.disable vga=795 nomodeset install-mode selinux=0 rd.live.overlay.overlayfs + echo Loading initrd... + initrd ($root)/boot/initrd +} + +menuentry "Kairos (manual)" --class os --unrestricted { + echo Loading kernel... + linux ($root)/boot/kernel cdroot root=live:CDLABEL=COS_LIVE rd.live.dir=/ rd.live.squashimg=rootfs.squashfs net.ifnames=1 console=ttyS0 console=tty1 rd.cos.disable vga=795 nomodeset selinux=0 rd.live.overlay.overlayfs + echo Loading initrd... + initrd ($root)/boot/initrd +} + +menuentry "kairos (interactive install)" --class os --unrestricted { + echo Loading kernel... + linux ($root)/boot/kernel cdroot root=live:CDLABEL=COS_LIVE rd.live.dir=/ rd.live.squashimg=rootfs.squashfs net.ifnames=1 console=ttyS0 console=tty1 rd.cos.disable vga=795 nomodeset install-mode-interactive selinux=0 rd.live.overlay.overlayfs + echo Loading initrd... + initrd ($root)/boot/initrd +} + +menuentry "Kairos (remote recovery mode)" --class os --unrestricted { + echo Loading kernel... + linux ($root)/boot/kernel cdroot root=live:CDLABEL=COS_LIVE rd.live.dir=/ rd.live.squashimg=rootfs.squashfs net.ifnames=1 console=ttyS0 console=tty1 rd.cos.disable vga=795 nomodeset kairos.remote_recovery_mode selinux=0 rd.live.overlay.overlayfs + echo Loading initrd... + initrd ($root)/boot/initrd +} + +menuentry "Kairos (boot local node from livecd)" --class os --unrestricted { + echo Loading kernel... + linux ($root)/boot/kernel cdroot root=live:CDLABEL=COS_LIVE rd.live.dir=/ rd.live.squashimg=rootfs.squashfs net.ifnames=1 console=ttyS0 console=tty1 kairos.boot_live_mode vga=795 nomodeset selinux=0 rd.live.overlay.overlayfs + echo Loading initrd... + initrd ($root)/boot/initrd +} + +menuentry "Kairos (debug)" --class os --unrestricted { + echo Loading kernel... + linux ($root)/boot/kernel cdroot root=live:CDLABEL=COS_LIVE rd.live.dir=/ rd.live.squashimg=rootfs.squashfs net.ifnames=1 console=tty0 rd.debug rd.shell rd.cos.disable rd.immucore.debug vga=795 nomodeset selinux=0 rd.live.overlay.overlayfs + echo Loading initrd... + initrd ($root)/boot/initrd +} + +if [ "${grub_platform}" = "efi" ]; then + hiddenentry "Text mode" --hotkey "t" { + set textmode=true + terminal_output console + } +fi diff --git a/pkg/ops/iso.go b/pkg/ops/iso.go index ea1c4ee..ba0eba5 100644 --- a/pkg/ops/iso.go +++ b/pkg/ops/iso.go @@ -5,20 +5,110 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "strings" + "time" "github.com/kairos-io/AuroraBoot/internal" + "github.com/kairos-io/AuroraBoot/pkg/constants" "github.com/kairos-io/AuroraBoot/pkg/schema" + "github.com/kairos-io/AuroraBoot/pkg/utils" "github.com/otiai10/copy" + "github.com/twpayne/go-vfs/v4" - enkiaction "github.com/kairos-io/enki/pkg/action" - enkiconfig "github.com/kairos-io/enki/pkg/config" - enkiconstants "github.com/kairos-io/enki/pkg/constants" - enkitypes "github.com/kairos-io/enki/pkg/types" - v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + "github.com/kairos-io/kairos-agent/v2/pkg/cloudinit" + agentconfig "github.com/kairos-io/kairos-agent/v2/pkg/config" + "github.com/kairos-io/kairos-agent/v2/pkg/elemental" + "github.com/kairos-io/kairos-agent/v2/pkg/http" + v1types "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" sdkTypes "github.com/kairos-io/kairos-sdk/types" - "github.com/kairos-io/kairos-sdk/utils" + sdkutils "github.com/kairos-io/kairos-sdk/utils" + "github.com/sanity-io/litter" ) +type LiveISO struct { + RootFS []*v1types.ImageSource `yaml:"rootfs,omitempty" mapstructure:"rootfs"` + UEFI []*v1types.ImageSource `yaml:"uefi,omitempty" mapstructure:"uefi"` + Image []*v1types.ImageSource `yaml:"image,omitempty" mapstructure:"image"` + Label string `yaml:"label,omitempty" mapstructure:"label"` + GrubEntry string `yaml:"grub-entry-name,omitempty" mapstructure:"grub-entry-name"` + BootloaderInRootFs bool `yaml:"bootloader-in-rootfs" mapstructure:"bootloader-in-rootfs"` +} + +// BuildConfig represents the config we need for building isos, raw images, artifacts +type BuildConfig struct { + Date bool `yaml:"date,omitempty" mapstructure:"date"` + Name string `yaml:"name,omitempty" mapstructure:"name"` + OutDir string `yaml:"output,omitempty" mapstructure:"output"` + + // 'inline' and 'squash' labels ensure config fields + // are embedded from a yaml and map PoV + agentconfig.Config `yaml:",inline" mapstructure:",squash"` +} + +type BuildISOAction struct { + cfg *BuildConfig + spec *LiveISO + e *elemental.Elemental +} + +type BuildISOActionOption func(a *BuildISOAction) +type GenericOptions func(a *agentconfig.Config) error + +func NewBuildConfig(opts ...GenericOptions) *BuildConfig { + b := &BuildConfig{ + Config: *NewConfig(opts...), + Name: constants.BuildImgName, + } + return b +} + +func NewConfig(opts ...GenericOptions) *agentconfig.Config { + log := sdkTypes.NewKairosLogger("auroraboot", "info", false) + arch, err := utils.GolangArchToArch(runtime.GOARCH) + if err != nil { + log.Errorf("invalid arch: %s", err.Error()) + return nil + } + + c := &agentconfig.Config{ + Fs: vfs.OSFS, + Logger: log, + Syscall: &v1types.RealSyscall{}, + Client: http.NewClient(), + Arch: arch, + SquashFsNoCompression: true, + } + for _, o := range opts { + err := o(c) + if err != nil { + log.Errorf("error applying config option: %s", err.Error()) + return nil + } + } + + // delay runner creation after we have run over the options in case we use WithRunner + if c.Runner == nil { + c.Runner = &v1types.RealRunner{Logger: &c.Logger} + } + + // Now check if the runner has a logger inside, otherwise point our logger into it + // This can happen if we set the WithRunner option as that doesn't set a logger + if c.Runner.GetLogger() == nil { + c.Runner.SetLogger(&c.Logger) + } + + // Delay the yip runner creation, so we set the proper logger instead of blindly setting it to the logger we create + // at the start of NewRunConfig, as WithLogger can be passed on init, and that would result in 2 different logger + // instances, on the config.Logger and the other on config.CloudInitRunner + if c.CloudInitRunner == nil { + c.CloudInitRunner = cloudinit.NewYipCloudInitRunner(c.Logger, c.Runner, vfs.OSFS) + } + litter.Config.HidePrivateFields = false + + return c +} + // GenISO generates an ISO from a rootfs, and stores results in dst func GenISO(src, dst string, i schema.ISO) func(ctx context.Context) error { return func(ctx context.Context) error { @@ -39,8 +129,8 @@ func GenISO(src, dst string, i schema.ISO) func(ctx context.Context) error { } internal.Log.Logger.Info().Msgf("Generating iso '%s' from '%s' to '%s'", i.Name, src, dst) - cfg := enkiconfig.NewBuildConfig( - enkiconfig.WithLogger(sdkTypes.NewKairosLogger("enki", "debug", false)), + cfg := NewBuildConfig( + WithLogger(sdkTypes.NewKairosLogger("auroraboot", "debug", false)), ) cfg.Name = i.Name cfg.OutDir = dst @@ -49,26 +139,25 @@ func GenISO(src, dst string, i schema.ISO) func(ctx context.Context) error { cfg.Arch = i.Arch } - spec := &enkitypes.LiveISO{ - RootFS: []*v1.ImageSource{v1.NewDirSrc(src)}, - Image: []*v1.ImageSource{v1.NewDirSrc("/grub2"), v1.NewDirSrc(overlay)}, - Label: enkiconstants.ISOLabel, + spec := &LiveISO{ + RootFS: []*v1types.ImageSource{v1types.NewDirSrc(src)}, + Image: []*v1types.ImageSource{v1types.NewDirSrc("/grub2"), v1types.NewDirSrc(overlay)}, + Label: constants.ISOLabel, GrubEntry: "Kairos", BootloaderInRootFs: false, } if i.OverlayRootfs != "" { - spec.RootFS = append(spec.RootFS, v1.NewDirSrc(i.OverlayRootfs)) + spec.RootFS = append(spec.RootFS, v1types.NewDirSrc(i.OverlayRootfs)) } if i.OverlayUEFI != "" { - // TODO: Doesn't seem to do anything on enki. - spec.UEFI = append(spec.UEFI, v1.NewDirSrc(i.OverlayUEFI)) + spec.UEFI = append(spec.UEFI, v1types.NewDirSrc(i.OverlayUEFI)) } if i.OverlayISO != "" { - spec.Image = append(spec.Image, v1.NewDirSrc(i.OverlayISO)) + spec.Image = append(spec.Image, v1types.NewDirSrc(i.OverlayISO)) } - buildISO := enkiaction.NewBuildISOAction(cfg, spec) + buildISO := NewBuildISOAction(cfg, spec) err = buildISO.ISORun() if err != nil { internal.Log.Logger.Error().Msgf("Failed generating iso '%s' from '%s'. Error: %s", i.Name, src, err.Error()) @@ -103,8 +192,8 @@ func InjectISO(dst, isoFile string, i schema.ISO) func(ctx context.Context) erro return err } - out, err := utils.SH(fmt.Sprintf("xorriso -indev %s -outdev %s -map %s / -boot_image any replay", isoFile, injectedIso, tmp)) - internal.Log.Logger.Print(out) + out, err := sdkutils.SH(fmt.Sprintf("xorriso -indev %s -outdev %s -map %s / -boot_image any replay", isoFile, injectedIso, tmp)) + internal.Log.Print(out) if err != nil { return err } @@ -123,3 +212,472 @@ func copyFileIfExists(src, dst string) error { } return copy.Copy(src, dst) } + +func NewBuildISOAction(cfg *BuildConfig, spec *LiveISO, opts ...BuildISOActionOption) *BuildISOAction { + b := &BuildISOAction{ + cfg: cfg, + e: elemental.NewElemental(&cfg.Config), + spec: spec, + } + for _, opt := range opts { + opt(b) + } + return b +} + +// ISORun will install the system from a given configuration +func (b *BuildISOAction) ISORun() (err error) { + cleanup := sdkutils.NewCleanStack() + defer func() { err = cleanup.Cleanup(err) }() + + isoTmpDir, err := utils.TempDir(b.cfg.Fs, "", "auroraboot-iso") + if err != nil { + return err + } + cleanup.Push(func() error { return b.cfg.Fs.RemoveAll(isoTmpDir) }) + + rootDir := filepath.Join(isoTmpDir, "rootfs") + err = utils.MkdirAll(b.cfg.Fs, rootDir, constants.DirPerm) + if err != nil { + return err + } + + uefiDir := filepath.Join(isoTmpDir, "uefi") + err = utils.MkdirAll(b.cfg.Fs, uefiDir, constants.DirPerm) + if err != nil { + return err + } + + isoDir := filepath.Join(isoTmpDir, "iso") + err = utils.MkdirAll(b.cfg.Fs, isoDir, constants.DirPerm) + if err != nil { + return err + } + + if b.cfg.OutDir != "" { + err = utils.MkdirAll(b.cfg.Fs, b.cfg.OutDir, constants.DirPerm) + if err != nil { + b.cfg.Logger.Errorf("Failed creating output folder: %s", b.cfg.OutDir) + return err + } + } + + b.cfg.Logger.Infof("Preparing squashfs root...") + err = b.applySources(rootDir, b.spec.RootFS...) + if err != nil { + b.cfg.Logger.Errorf("Failed installing OS packages: %v", err) + return err + } + err = utils.CreateDirStructure(b.cfg.Fs, rootDir) + if err != nil { + b.cfg.Logger.Errorf("Failed creating root directory structure: %v", err) + return err + } + + b.cfg.Logger.Infof("Preparing ISO image root tree...") + err = b.applySources(isoDir, b.spec.Image...) + if err != nil { + b.cfg.Logger.Errorf("Failed installing ISO image packages: %v", err) + return err + } + + err = b.prepareISORoot(isoDir, rootDir) + if err != nil { + b.cfg.Logger.Errorf("Failed preparing ISO's root tree: %v", err) + return err + } + + err = b.prepareBootArtifacts(isoDir) + if err != nil { + b.cfg.Logger.Errorf("Failed preparing boot artifacts: %v", err) + return err + } + + b.cfg.Logger.Infof("Creating ISO image...") + err = b.burnISO(isoDir) + if err != nil { + b.cfg.Logger.Errorf("Failed creating ISO image: %v", err) + return err + } + + return err +} + +// prepareBootArtifacts will write the needed artifacts for BIOS cd boot into the isoDir +// so xorriso can use those to build the bootable iso file +func (b *BuildISOAction) prepareBootArtifacts(isoDir string) error { + err := os.WriteFile(filepath.Join(isoDir, constants.IsoBootFile), constants.Eltorito, constants.FilePerm) + if err != nil { + return err + } + err = os.WriteFile(filepath.Join(isoDir, constants.IsoHybridMBR), constants.BootHybrid, constants.FilePerm) + if err != nil { + return err + } + err = os.MkdirAll(filepath.Join(isoDir, constants.GrubPrefixDir), constants.DirPerm) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(isoDir, constants.GrubPrefixDir, constants.GrubCfg), constants.GrubLiveBiosCfg, constants.FilePerm) +} + +func (b BuildISOAction) prepareISORoot(isoDir string, rootDir string) error { + kernel, initrd, err := b.e.FindKernelInitrd(rootDir) + if err != nil { + b.cfg.Logger.Error("Could not find kernel and/or initrd") + return err + } + err = utils.MkdirAll(b.cfg.Fs, filepath.Join(isoDir, "boot"), constants.DirPerm) + if err != nil { + return err + } + //TODO document boot/kernel and boot/initrd expectation in bootloader config + b.cfg.Logger.Debugf("Copying Kernel file %s to iso root tree", kernel) + err = utils.CopyFile(b.cfg.Fs, kernel, filepath.Join(isoDir, constants.IsoKernelPath)) + if err != nil { + return err + } + + b.cfg.Logger.Debugf("Copying initrd file %s to iso root tree", initrd) + err = utils.CopyFile(b.cfg.Fs, initrd, filepath.Join(isoDir, constants.IsoInitrdPath)) + if err != nil { + return err + } + + b.cfg.Logger.Info("Creating EFI image...") + err = b.createEFI(rootDir, isoDir) + if err != nil { + return err + } + + b.cfg.Logger.Info("Creating squashfs...") + err = utils.CreateSquashFS(b.cfg.Runner, b.cfg.Logger, rootDir, filepath.Join(isoDir, constants.IsoRootFile), constants.GetDefaultSquashfsOptions()) + if err != nil { + return err + } + + return nil +} + +// createEFI creates the EFI image that is used for booting +// it searches the rootfs for the shim/grub.efi file and copies it into a directory with the proper EFI structure +// then it generates a grub.cfg that chainloads into the grub.cfg of the livecd (which is the normal livecd grub config from luet packages) +// then it calculates the size of the EFI image based on the files copied and creates the image +func (b BuildISOAction) createEFI(rootdir string, isoDir string) error { + var err error + + // rootfs /efi dir + img := filepath.Join(isoDir, constants.IsoEFIPath) + temp, _ := utils.TempDir(b.cfg.Fs, "", "auroraboot-iso") + err = utils.MkdirAll(b.cfg.Fs, filepath.Join(temp, constants.EfiBootPath), constants.DirPerm) + if err != nil { + b.cfg.Logger.Errorf("Failed creating temp efi dir: %v", err) + return err + } + err = utils.MkdirAll(b.cfg.Fs, filepath.Join(isoDir, constants.EfiBootPath), constants.DirPerm) + if err != nil { + b.cfg.Logger.Errorf("Failed creating iso efi dir: %v", err) + return err + } + + err = b.copyShim(temp, rootdir) + if err != nil { + return err + } + + err = b.copyGrub(temp, rootdir) + if err != nil { + return err + } + + // Generate grub cfg that chainloads into the default livecd grub under /boot/grub2/grub.cfg + // Its read from the root of the livecd, so we need to copy it into /EFI/BOOT/grub.cfg + // This is due to the hybrid bios/efi boot mode of the livecd + // the uefi.img is loaded into memory and run, but grub only sees the livecd root + err = b.cfg.Fs.WriteFile(filepath.Join(isoDir, constants.EfiBootPath, constants.GrubCfg), []byte(constants.GrubEfiCfg), constants.FilePerm) + if err != nil { + b.cfg.Logger.Errorf("Failed writing grub.cfg: %v", err) + return err + } + // Ubuntu efi searches for the grub.cfg file under /EFI/ubuntu/grub.cfg while we store it under /boot/grub2/grub.cfg + // workaround this by copying it there as well + // read the kairos-release from the rootfs to know if we are creating a ubuntu based iso + var flavor string + flavor, err = sdkutils.OSRelease("FLAVOR", filepath.Join(rootdir, "etc/kairos-release")) + if err != nil { + // fallback to os-release + flavor, err = sdkutils.OSRelease("FLAVOR", filepath.Join(rootdir, "etc/os-release")) + if err != nil { + b.cfg.Logger.Warnf("Failed reading os-release from %s and %s: %v", filepath.Join(rootdir, "etc/kairos-release"), filepath.Join(rootdir, "etc/os-release"), err) + return err + } + } + b.cfg.Logger.Infof("Detected Flavor: %s", flavor) + if strings.Contains(strings.ToLower(flavor), "ubuntu") { + b.cfg.Logger.Infof("Ubuntu based ISO detected, copying grub.cfg to /EFI/ubuntu/grub.cfg") + err = utils.MkdirAll(b.cfg.Fs, filepath.Join(isoDir, "EFI/ubuntu/"), constants.DirPerm) + if err != nil { + b.cfg.Logger.Errorf("Failed writing grub.cfg: %v", err) + return err + } + err = b.cfg.Fs.WriteFile(filepath.Join(isoDir, "EFI/ubuntu/", constants.GrubCfg), []byte(constants.GrubEfiCfg), constants.FilePerm) + if err != nil { + b.cfg.Logger.Errorf("Failed writing grub.cfg: %v", err) + return err + } + } + + // Calculate EFI image size based on artifacts + efiSize, err := utils.DirSize(b.cfg.Fs, temp) + if err != nil { + return err + } + // align efiSize to the next 4MB slot + align := int64(4 * 1024 * 1024) + efiSizeMB := (efiSize/align*align + align) / (1024 * 1024) + // Create the actual efi image + err = b.e.CreateFileSystemImage(&v1types.Image{ + File: img, + Size: uint(efiSizeMB), + FS: constants.EfiFs, + Label: constants.EfiLabel, + }) + if err != nil { + return err + } + b.cfg.Logger.Debugf("EFI image created at %s", img) + // copy the files from the temporal efi dir into the EFI image + files, err := b.cfg.Fs.ReadDir(temp) + if err != nil { + return err + } + + for _, f := range files { + // This copies the efi files into the efi img used for the boot + b.cfg.Logger.Debugf("Copying %s to %s", filepath.Join(temp, f.Name()), img) + _, err = b.cfg.Runner.Run("mcopy", "-s", "-i", img, filepath.Join(temp, f.Name()), "::") + if err != nil { + b.cfg.Logger.Errorf("Failed copying %s to %s: %v", filepath.Join(temp, f.Name()), img, err) + return err + } + } + + return nil +} + +// copyShim copies the shim files into the EFI partition +// tempdir is the temp dir where the EFI image is generated from +// rootdir is the rootfs where the shim files are searched for +func (b BuildISOAction) copyShim(tempdir, rootdir string) error { + var fallBackShim string + var err error + // Get possible shim file paths + shimFiles := sdkutils.GetEfiShimFiles(b.cfg.Arch) + // Calculate shim path based on arch + var shimDest string + switch b.cfg.Arch { + case constants.ArchAmd64, constants.Archx86: + shimDest = filepath.Join(tempdir, constants.ShimEfiDest) + fallBackShim = filepath.Join("/efi", constants.EfiBootPath, "bootx64.efi") + case constants.ArchArm64: + shimDest = filepath.Join(tempdir, constants.ShimEfiArmDest) + fallBackShim = filepath.Join("/efi", constants.EfiBootPath, "bootaa64.efi") + default: + err = fmt.Errorf("not supported architecture: %v", b.cfg.Arch) + } + var shimDone bool + for _, f := range shimFiles { + _, err := b.cfg.Fs.Stat(filepath.Join(rootdir, f)) + if err != nil { + b.cfg.Logger.Debugf("skip copying %s: not found", filepath.Join(rootdir, f)) + continue + } + b.cfg.Logger.Debugf("Copying %s to %s", filepath.Join(rootdir, f), shimDest) + err = utils.CopyFile( + b.cfg.Fs, + filepath.Join(rootdir, f), + shimDest, + ) + if err != nil { + b.cfg.Logger.Warnf("error reading %s: %s", filepath.Join(rootdir, f), err) + continue + } + shimDone = true + break + } + if !shimDone { + // All failed...maybe we are on alpine which doesnt provide shim/grub.efi ? + // In that case, we can just use the luet packaged artifacts + err = utils.CopyFile( + b.cfg.Fs, + fallBackShim, + shimDest, + ) + if err != nil { + b.cfg.Logger.Debugf("List of shim files searched for in %s: %s", rootdir, shimFiles) + return fmt.Errorf("could not find any shim file to copy") + } + b.cfg.Logger.Debugf("Using fallback shim file %s", fallBackShim) + // Also copy the shim.efi file into the rootfs so the installer can find it. Side effect of + // alpine not providing shim/grub.efi and we not providing it from packages anymore + _ = utils.MkdirAll(b.cfg.Fs, filepath.Join(rootdir, filepath.Dir(shimFiles[0])), constants.DirPerm) + err = utils.CopyFile( + b.cfg.Fs, + fallBackShim, + filepath.Join(rootdir, shimFiles[0]), + ) + if err != nil { + b.cfg.Logger.Debugf("Could not copy fallback shim into rootfs from %s to %s", fallBackShim, filepath.Join(rootdir, shimFiles[0])) + return fmt.Errorf("could not copy fallback shim into rootfs from %s to %s", fallBackShim, filepath.Join(rootdir, shimFiles[0])) + } + } + return err +} + +// copyGrub copies the shim files into the EFI partition +// tempdir is the temp dir where the EFI image is generated from +// rootdir is the rootfs where the shim files are searched for +func (b BuildISOAction) copyGrub(tempdir, rootdir string) error { + // this is shipped usually with osbuilder and the files come from livecd/grub2-efi-artifacts + var fallBackGrub = filepath.Join("/efi", constants.EfiBootPath, "grub.efi") + var err error + // Get possible grub file paths + grubFiles := sdkutils.GetEfiGrubFiles(b.cfg.Arch) + var grubDone bool + for _, f := range grubFiles { + stat, err := b.cfg.Fs.Stat(filepath.Join(rootdir, f)) + if err != nil { + b.cfg.Logger.Debugf("skip copying %s: not found", filepath.Join(rootdir, f)) + continue + } + // Same name as the source, shim looks for that name. We need to remove the .signed suffix + nameDest := filepath.Join(tempdir, "EFI/BOOT", cleanupGrubName(stat.Name())) + b.cfg.Logger.Debugf("Copying %s to %s", filepath.Join(rootdir, f), nameDest) + + err = utils.CopyFile( + b.cfg.Fs, + filepath.Join(rootdir, f), + nameDest, + ) + if err != nil { + b.cfg.Logger.Warnf("error reading %s: %s", filepath.Join(rootdir, f), err) + continue + } + grubDone = true + break + } + if !grubDone { + // All failed...maybe we are on alpine which doesnt provide shim/grub.efi ? + // In that case, we can just use the luet packaged artifacts + err = utils.CopyFile( + b.cfg.Fs, + fallBackGrub, + filepath.Join(tempdir, "EFI/BOOT/grub.efi"), + ) + if err != nil { + b.cfg.Logger.Debugf("List of grub files searched for: %s", grubFiles) + return fmt.Errorf("could not find any grub efi file to copy") + } + b.cfg.Logger.Debugf("Using fallback grub file %s", fallBackGrub) + // Also copy the grub.efi file into the rootfs so the installer can find it. Side effect of + // alpine not providing shim/grub.efi and we not providing it from packages anymore + utils.MkdirAll(b.cfg.Fs, filepath.Join(rootdir, filepath.Dir(grubFiles[0])), constants.DirPerm) + err = utils.CopyFile( + b.cfg.Fs, + fallBackGrub, + filepath.Join(rootdir, grubFiles[0]), + ) + if err != nil { + b.cfg.Logger.Debugf("Could not copy fallback grub into rootfs from %s to %s", fallBackGrub, filepath.Join(rootdir, grubFiles[0])) + return fmt.Errorf("could not copy fallback shim into rootfs from %s to %s", fallBackGrub, filepath.Join(rootdir, grubFiles[0])) + } + } + return err +} + +func (b BuildISOAction) burnISO(root string) error { + cmd := "xorriso" + var outputFile string + var isoFileName string + + if b.cfg.Date { + currTime := time.Now() + isoFileName = fmt.Sprintf("%s.%s.iso", b.cfg.Name, currTime.Format("20060102")) + } else { + isoFileName = fmt.Sprintf("%s.iso", b.cfg.Name) + } + + outputFile = isoFileName + if b.cfg.OutDir != "" { + outputFile = filepath.Join(b.cfg.OutDir, outputFile) + } + + if exists, _ := utils.Exists(b.cfg.Fs, outputFile); exists { + b.cfg.Logger.Warnf("Overwriting already existing %s", outputFile) + err := b.cfg.Fs.Remove(outputFile) + if err != nil { + return err + } + } + + args := []string{ + "-volid", b.spec.Label, "-joliet", "on", "-padding", "0", + "-outdev", outputFile, "-map", root, "/", "-chmod", "0755", "--", + } + args = append(args, constants.GetXorrisoBooloaderArgs(root)...) + + out, err := b.cfg.Runner.Run(cmd, args...) + b.cfg.Logger.Debugf("Xorriso: %s", string(out)) + if err != nil { + return err + } + + checksum, err := utils.CalcFileChecksum(b.cfg.Fs, outputFile) + if err != nil { + return fmt.Errorf("checksum computation failed: %w", err) + } + err = b.cfg.Fs.WriteFile(fmt.Sprintf("%s.sha256", outputFile), []byte(fmt.Sprintf("%s %s\n", checksum, isoFileName)), 0644) + if err != nil { + return fmt.Errorf("cannot write checksum file: %w", err) + } + + return nil +} + +func (b BuildISOAction) applySources(target string, sources ...*v1types.ImageSource) error { + for _, src := range sources { + _, err := b.e.DumpSource(target, src) + if err != nil { + return err + } + } + return nil +} + +// cleanupGrubName will cleanup the grub name to provide a proper grub named file +// As the original name can contain several suffixes to indicate its signed status +// we need to clean them up before using them as the shim will look for a file with +// no suffixes +func cleanupGrubName(name string) string { + // remove the .signed suffix if present + clean := strings.TrimSuffix(name, ".signed") + // remove the .dualsigned suffix if present + clean = strings.TrimSuffix(clean, ".dualsigned") + // remove the .signed.latest suffix if present + clean = strings.TrimSuffix(clean, ".signed.latest") + return clean +} + +func WithLogger(logger sdkTypes.KairosLogger) func(r *agentconfig.Config) error { + return func(r *agentconfig.Config) error { + r.Logger = logger + return nil + } +} + +func WithImageExtractor(extractor v1types.ImageExtractor) func(r *agentconfig.Config) error { + return func(r *agentconfig.Config) error { + r.ImageExtractor = extractor + return nil + } +} diff --git a/pkg/utils/common.go b/pkg/utils/common.go new file mode 100644 index 0000000..800a66e --- /dev/null +++ b/pkg/utils/common.go @@ -0,0 +1,316 @@ +package utils + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + containerdCompression "github.com/containerd/containerd/archive/compression" + "github.com/google/go-containerregistry/pkg/name" + container "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/kairos-io/AuroraBoot/pkg/constants" + v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + sdkTypes "github.com/kairos-io/kairos-sdk/types" + "github.com/spf13/viper" +) + +type BootEntry struct { + FileName string + Cmdline string + Title string +} + +// CreateSquashFS creates a squash file at destination from a source, with options +// TODO: Check validity of source maybe? +func CreateSquashFS(runner v1.Runner, logger sdkTypes.KairosLogger, source string, destination string, options []string) error { + // create args + args := []string{source, destination} + // append options passed to args in order to have the correct order + // protect against options passed together in the same string , i.e. "-x add" instead of "-x", "add" + var optionsExpanded []string + for _, op := range options { + optionsExpanded = append(optionsExpanded, strings.Split(op, " ")...) + } + args = append(args, optionsExpanded...) + out, err := runner.Run("mksquashfs", args...) + if err != nil { + logger.Debugf("Error running squashfs creation, stdout: %s", out) + logger.Errorf("Error while creating squashfs from %s to %s: %s", source, destination, err) + return err + } + return nil +} + +func GolangArchToArch(arch string) (string, error) { + switch strings.ToLower(arch) { + case constants.ArchAmd64: + return constants.Archx86, nil + case constants.ArchArm64: + return constants.ArchArm64, nil + default: + return "", fmt.Errorf("invalid arch") + } +} + +// GetUkiCmdline returns the cmdline to be used for the kernel. +// The cmdline can be overridden by the user using the cmdline flag. +// For each cmdline passed, we generate a uki file with that cmdline +// extend-cmdline will just extend the default cmdline so we only create one efi file +// extra-cmdline will create a new efi file for each cmdline passed +func GetUkiCmdline() []BootEntry { + defaultCmdLine := constants.UkiCmdline + " " + constants.UkiCmdlineInstall + + // Extend only + cmdlineExtend := viper.GetString("extend-cmdline") + if cmdlineExtend != "" { + cmdline := defaultCmdLine + " " + cmdlineExtend + return []BootEntry{{ + Cmdline: cmdline, + Title: viper.GetString("boot-branding"), + FileName: NameFromCmdline(constants.ArtifactBaseName, cmdline), + }} + } + + // default entry + result := []BootEntry{{ + Cmdline: defaultCmdLine, + Title: viper.GetString("boot-branding"), + FileName: NameFromCmdline(constants.ArtifactBaseName, defaultCmdLine), + }} + + // extra + for _, extra := range viper.GetStringSlice("extra-cmdline") { + cmdline := defaultCmdLine + " " + extra + result = append(result, BootEntry{ + Cmdline: cmdline, + Title: viper.GetString("boot-branding"), + FileName: NameFromCmdline(constants.ArtifactBaseName, cmdline), + }) + } + + return result +} + +// GetUkiSingleCmdlines returns the single-efi-cmdline as passed by the user. +func GetUkiSingleCmdlines(logger sdkTypes.KairosLogger) []BootEntry { + result := []BootEntry{} + // extra + defaultCmdLine := constants.UkiCmdline + " " + constants.UkiCmdlineInstall + + cmdlines := viper.GetStringSlice("single-efi-cmdline") + for _, userValue := range cmdlines { + bootEntry := BootEntry{} + + before, after, hasTitle := strings.Cut(userValue, ":") + if hasTitle { + bootEntry.Title = fmt.Sprintf("%s (%s)", viper.GetString("boot-branding"), before) + bootEntry.Cmdline = defaultCmdLine + " " + after + bootEntry.FileName = strings.ReplaceAll(before, " ", "_") + } else { + bootEntry.Title = viper.GetString("boot-branding") + bootEntry.Cmdline = defaultCmdLine + " " + before + bootEntry.FileName = NameFromCmdline("single_entry", before) + } + result = append(result, bootEntry) + } + + return result +} + +// Tar takes a source and variable writers and walks 'source' writing each file +// found to the tar writer; the purpose for accepting multiple writers is to allow +// for multiple outputs (for example a file, or md5 hash) +func Tar(src string, writers ...io.Writer) error { + // ensure the src actually exists before trying to tar it + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("Unable to tar files - %v", err.Error()) + } + + mw := io.MultiWriter(writers...) + + gzw := gzip.NewWriter(mw) + defer gzw.Close() + + tw := tar.NewWriter(gzw) + defer tw.Close() + + // walk path + return filepath.Walk(src, func(file string, fi os.FileInfo, err error) error { + + // return on any error + if err != nil { + return err + } + + // return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update) + if !fi.Mode().IsRegular() { + return nil + } + + // create a new dir/file header + header, err := tar.FileInfoHeader(fi, fi.Name()) + if err != nil { + return err + } + + // update the name to correctly reflect the desired destination when untaring + header.Name = strings.TrimPrefix(strings.ReplaceAll(file, src, ""), string(filepath.Separator)) + + // write the header + if err := tw.WriteHeader(header); err != nil { + return err + } + + // open files for taring + f, err := os.Open(file) + if err != nil { + return err + } + + // copy file data into tar writer + if _, err := io.Copy(tw, f); err != nil { + return err + } + + // manually close here after each file operation; defering would cause each file close + // to wait until all operations have completed. + f.Close() + + return nil + }) +} + +// CreateTar a imagetarball from a standard tarball +func CreateTar(log sdkTypes.KairosLogger, srctar, dstimageTar, imagename, architecture, OS string) error { + + dstFile, err := os.Create(dstimageTar) + if err != nil { + return fmt.Errorf("Cannot create %s: %s", dstimageTar, err) + } + defer dstFile.Close() + + newRef, img, err := imageFromTar(imagename, architecture, OS, func() (io.ReadCloser, error) { + f, err := os.Open(srctar) + if err != nil { + return nil, fmt.Errorf("cannot open %s: %s", srctar, err) + } + decompressed, err := containerdCompression.DecompressStream(f) + if err != nil { + return nil, fmt.Errorf("cannot open %s: %s", srctar, err) + } + + return decompressed, nil + }) + if err != nil { + return err + } + + // Lets try to load it into the docker daemon? + // Code left here in case we want to use it in the future + /* + tag, err := name.NewTag(imagename) + + if err != nil { + log.Warnf("Cannot create tag for %s: %s", imagename, err) + } + if err == nil { + // Best effort only, just try and forget + out, err := daemon.Write(tag, img) + if err != nil { + log.Warnf("Cannot write image %s to daemon: %s\noutput: %s", imagename, err, out) + } else { + log.Infof("Image %s written to daemon", tag.String()) + } + } + */ + + return tarball.Write(newRef, img, dstFile) + +} + +func imageFromTar(imagename, architecture, OS string, opener func() (io.ReadCloser, error)) (name.Reference, container.Image, error) { + newRef, err := name.ParseReference(imagename) + if err != nil { + return nil, nil, err + } + + layer, err := tarball.LayerFromOpener(opener) + if err != nil { + return nil, nil, err + } + + baseImage := empty.Image + cfg, err := baseImage.ConfigFile() + if err != nil { + return nil, nil, err + } + + cfg.Architecture = architecture + cfg.OS = OS + + baseImage, err = mutate.ConfigFile(baseImage, cfg) + if err != nil { + return nil, nil, err + } + img, err := mutate.Append(baseImage, mutate.Addendum{ + Layer: layer, + History: container.History{ + CreatedBy: "Enki", + Comment: "Custom image", + Created: container.Time{Time: time.Now()}, + }, + }) + if err != nil { + return nil, nil, err + } + + return newRef, img, nil +} + +func IsAmd64(arch string) bool { + return arch == constants.ArchAmd64 || arch == constants.Archx86 +} + +func IsArm64(arch string) bool { + return arch == constants.ArchArm64 || arch == constants.Archaarch64 +} + +// NameFromCmdline returns the name of the efi/conf file based on the cmdline +// we want to have at least 1 efi file that its the default, that is the one we ship with the iso/media/whatever install medium +// that one has the default cmdline + the install cmdline +// For that one, we use it as the BASE one, configs will only trigger for that install stanza if we are on install media +// so we dont have to worry about it, but we want to provide a clean name for it +// so in that case we dont add anything to the efi name/conf name/cmdline inside the config +// For the other ones, we add the cmdline to the efi name and the cmdline to the conf file +// so you get +// - norole.efi +// - norole.conf +// - norole_interactive-install.efi +// - norole_interactive-install.conf +// This is mostly for convenience in generating the names as the real data is stored in the config file +// but it can easily be used to identify the efi file and the conf file. +func NameFromCmdline(basename, cmdline string) string { + // Remove the default cmdline from the current cmdline + cmdlineForEfi := strings.TrimSpace(strings.TrimPrefix(cmdline, constants.UkiCmdline)) + // For the default install entry, do not add anything on the efi name + if cmdlineForEfi == constants.UkiCmdlineInstall { + cmdlineForEfi = "" + } + // Although only slashes are truly forbidden, we also replace other characters, + // as they can be problematic when interpreted by the shell (e.g. &, |, etc.) + allowedChars := regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + cleanCmdline := allowedChars.ReplaceAllString(cmdlineForEfi, "_") + name := basename + "_" + cleanCmdline + // If the cmdline is empty, we remove the underscore as to not get a dangling one + finalName := strings.TrimSuffix(name, "_") + return finalName +} diff --git a/pkg/utils/fs.go b/pkg/utils/fs.go new file mode 100644 index 0000000..7a8b2c6 --- /dev/null +++ b/pkg/utils/fs.go @@ -0,0 +1,224 @@ +package utils + +import ( + "crypto/sha256" + "fmt" + "io" + iofs "io/fs" + "os" + "path/filepath" + "strconv" + "sync" + "syscall" + "time" + + "github.com/kairos-io/AuroraBoot/pkg/constants" + v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" +) + +// MkdirAll directory and all parents if not existing +func MkdirAll(fs v1.FS, name string, mode os.FileMode) (err error) { + if _, isReadOnly := fs.(*vfs.ReadOnlyFS); isReadOnly { + return permError("mkdir", name) + } + if name, err = fs.RawPath(name); err != nil { + return &os.PathError{Op: "mkdir", Path: name, Err: err} + } + return os.MkdirAll(name, mode) +} + +// permError returns an *os.PathError with Err syscall.EPERM. +func permError(op, path string) error { + return &os.PathError{ + Op: op, + Path: path, + Err: syscall.EPERM, + } +} + +// Copies source file to target file using Fs interface +func CreateDirStructure(fs v1.FS, target string) error { + for _, dir := range []string{"/run", "/dev", "/boot", "/usr/local", "/oem"} { + err := MkdirAll(fs, filepath.Join(target, dir), constants.DirPerm) + if err != nil { + return err + } + } + for _, dir := range []string{"/proc", "/sys"} { + err := MkdirAll(fs, filepath.Join(target, dir), constants.NoWriteDirPerm) + if err != nil { + return err + } + } + err := MkdirAll(fs, filepath.Join(target, "/tmp"), constants.DirPerm) + if err != nil { + return err + } + // Set /tmp permissions regardless the umask setup + err = fs.Chmod(filepath.Join(target, "/tmp"), constants.TempDirPerm) + if err != nil { + return err + } + return nil +} + +// TempDir creates a temp file in the virtual fs +// Took from afero.FS code and adapted +func TempDir(fs v1.FS, dir, prefix string) (name string, err error) { + if dir == "" { + dir = os.TempDir() + } + // This skips adding random stuff to the created temp dir so the temp dir created is predictable for testing + if _, isTestFs := fs.(*vfst.TestFS); isTestFs { + err = MkdirAll(fs, filepath.Join(dir, prefix), 0700) + if err != nil { + return "", err + } + name = filepath.Join(dir, prefix) + return + } + nconflict := 0 + for i := 0; i < 10000; i++ { + try := filepath.Join(dir, prefix+nextRandom()) + err = MkdirAll(fs, try, 0700) + if os.IsExist(err) { + if nconflict++; nconflict > 10 { + randmu.Lock() + rand = reseed() + randmu.Unlock() + } + continue + } + if err == nil { + name = try + } + break + } + return +} + +// Random number state. +// We generate random temporary file names so that there's a good +// chance the file doesn't exist yet - keeps the number of tries in +// TempFile to a minimum. +var rand uint32 +var randmu sync.Mutex + +func reseed() uint32 { + return uint32(time.Now().UnixNano() + int64(os.Getpid())) +} + +func nextRandom() string { + randmu.Lock() + r := rand + if r == 0 { + r = reseed() + } + r = r*1664525 + 1013904223 // constants from Numerical Recipes + rand = r + randmu.Unlock() + return strconv.Itoa(int(1e9 + r%1e9))[1:] +} + +// CopyFile Copies source file to target file using Fs interface. If target +// is directory source is copied into that directory using source name file. +func CopyFile(fs v1.FS, source string, target string) (err error) { + return ConcatFiles(fs, []string{source}, target) +} + +// IsDir check if the path is a dir +func IsDir(fs v1.FS, path string) (bool, error) { + fi, err := fs.Stat(path) + if err != nil { + return false, err + } + return fi.IsDir(), nil +} + +// ConcatFiles Copies source files to target file using Fs interface. +// Source files are concatenated into target file in the given order. +// If target is a directory source is copied into that directory using +// 1st source name file. +func ConcatFiles(fs v1.FS, sources []string, target string) (err error) { + if len(sources) == 0 { + return fmt.Errorf("Empty sources list") + } + if dir, _ := IsDir(fs, target); dir { + target = filepath.Join(target, filepath.Base(sources[0])) + } + + targetFile, err := fs.Create(target) + if err != nil { + return err + } + defer func() { + if err == nil { + err = targetFile.Close() + } else { + _ = fs.Remove(target) + } + }() + + var sourceFile iofs.File + for _, source := range sources { + sourceFile, err = fs.Open(source) + if err != nil { + break + } + _, err = io.Copy(targetFile, sourceFile) + if err != nil { + break + } + err = sourceFile.Close() + if err != nil { + break + } + } + + return err +} + +// DirSize returns the accumulated size of all files in folder +func DirSize(fs v1.FS, path string) (int64, error) { + var size int64 + err := vfs.Walk(fs, path, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + size += info.Size() + } + return err + }) + return size, err +} + +// Check if a file or directory exists. +func Exists(fs v1.FS, path string) (bool, error) { + _, err := fs.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// CalcFileChecksum opens the given file and returns the sha256 checksum of it. +func CalcFileChecksum(fs v1.FS, fileName string) (string, error) { + f, err := fs.Open(fileName) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/utils/utils_suite_test.go b/pkg/utils/utils_suite_test.go new file mode 100644 index 0000000..ec88947 --- /dev/null +++ b/pkg/utils/utils_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright © 2021 SUSE LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWhitebox(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils test suite") +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 0000000..984e17f --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,246 @@ +/* +Copyright © 2021 SUSE LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils_test + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/kairos-io/AuroraBoot/pkg/constants" + "github.com/kairos-io/AuroraBoot/pkg/utils" + v1mock "github.com/kairos-io/kairos-agent/v2/tests/mocks" + sdkTypes "github.com/kairos-io/kairos-sdk/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/viper" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" +) + +var _ = Describe("Utils", Label("utils"), func() { + var runner *v1mock.FakeRunner + var logger sdkTypes.KairosLogger + var fs vfs.FS + var cleanup func() + + BeforeEach(func() { + runner = v1mock.NewFakeRunner() + logger = sdkTypes.NewNullLogger() + // Ensure /tmp exists in the VFS + fs, cleanup, _ = vfst.NewTestFS(nil) + fs.Mkdir("/tmp", constants.DirPerm) + fs.Mkdir("/run", constants.DirPerm) + fs.Mkdir("/etc", constants.DirPerm) + + }) + AfterEach(func() { cleanup() }) + Describe("CopyFile", Label("CopyFile"), func() { + It("Copies source file to target file", func() { + err := utils.MkdirAll(fs, "/some", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + _, err = fs.Create("/some/file") + Expect(err).ShouldNot(HaveOccurred()) + _, err = fs.Stat("/some/otherfile") + Expect(err).Should(HaveOccurred()) + Expect(utils.CopyFile(fs, "/some/file", "/some/otherfile")).ShouldNot(HaveOccurred()) + e, err := utils.Exists(fs, "/some/otherfile") + Expect(err).ShouldNot(HaveOccurred()) + Expect(e).To(BeTrue()) + }) + It("Copies source file to target folder", func() { + err := utils.MkdirAll(fs, "/some", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + err = utils.MkdirAll(fs, "/someotherfolder", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + _, err = fs.Create("/some/file") + Expect(err).ShouldNot(HaveOccurred()) + _, err = fs.Stat("/someotherfolder/file") + Expect(err).Should(HaveOccurred()) + Expect(utils.CopyFile(fs, "/some/file", "/someotherfolder")).ShouldNot(HaveOccurred()) + e, err := utils.Exists(fs, "/someotherfolder/file") + Expect(err).ShouldNot(HaveOccurred()) + Expect(e).To(BeTrue()) + }) + It("Fails to open non existing file", func() { + err := utils.MkdirAll(fs, "/some", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + Expect(utils.CopyFile(fs, "/some/file", "/some/otherfile")).NotTo(BeNil()) + _, err = fs.Stat("/some/otherfile") + Expect(err).NotTo(BeNil()) + }) + It("Fails to copy on non writable target", func() { + err := utils.MkdirAll(fs, "/some", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + fs.Create("/some/file") + _, err = fs.Stat("/some/otherfile") + Expect(err).NotTo(BeNil()) + fs = vfs.NewReadOnlyFS(fs) + Expect(utils.CopyFile(fs, "/some/file", "/some/otherfile")).NotTo(BeNil()) + _, err = fs.Stat("/some/otherfile") + Expect(err).NotTo(BeNil()) + }) + }) + Describe("CreateDirStructure", Label("CreateDirStructure"), func() { + It("Creates essential directories", func() { + dirList := []string{"sys", "proc", "dev", "tmp", "boot", "usr/local", "oem"} + for _, dir := range dirList { + _, err := fs.Stat(fmt.Sprintf("/my/root/%s", dir)) + Expect(err).NotTo(BeNil()) + } + Expect(utils.CreateDirStructure(fs, "/my/root")).To(BeNil()) + for _, dir := range dirList { + fi, err := fs.Stat(fmt.Sprintf("/my/root/%s", dir)) + Expect(err).To(BeNil()) + if fi.Name() == "tmp" { + Expect(fmt.Sprintf("%04o", fi.Mode().Perm())).To(Equal("0777")) + Expect(fi.Mode() & os.ModeSticky).NotTo(Equal(0)) + } + if fi.Name() == "sys" { + Expect(fmt.Sprintf("%04o", fi.Mode().Perm())).To(Equal("0555")) + } + } + }) + It("Fails on non writable target", func() { + fs = vfs.NewReadOnlyFS(fs) + Expect(utils.CreateDirStructure(fs, "/my/root")).NotTo(BeNil()) + }) + }) + Describe("DirSize", Label("fs"), func() { + BeforeEach(func() { + err := utils.MkdirAll(fs, "/folder/subfolder", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + f, err := fs.Create("/folder/file") + Expect(err).ShouldNot(HaveOccurred()) + err = f.Truncate(1024) + Expect(err).ShouldNot(HaveOccurred()) + f, err = fs.Create("/folder/subfolder/file") + Expect(err).ShouldNot(HaveOccurred()) + err = f.Truncate(2048) + Expect(err).ShouldNot(HaveOccurred()) + }) + It("Returns the expected size of a test folder", func() { + size, err := utils.DirSize(fs, "/folder") + Expect(err).ShouldNot(HaveOccurred()) + Expect(size).To(Equal(int64(3072))) + }) + }) + Describe("CalcFileChecksum", Label("checksum"), func() { + It("compute correct sha256 checksum", func() { + testData := strings.Repeat("abcdefghilmnopqrstuvz\n", 20) + testDataSHA256 := "7f182529f6362ae9cfa952ab87342a7180db45d2c57b52b50a68b6130b15a422" + + err := fs.Mkdir("/iso", constants.DirPerm) + Expect(err).ShouldNot(HaveOccurred()) + err = fs.WriteFile("/iso/test.iso", []byte(testData), 0644) + Expect(err).ShouldNot(HaveOccurred()) + + checksum, err := utils.CalcFileChecksum(fs, "/iso/test.iso") + Expect(err).ShouldNot(HaveOccurred()) + Expect(checksum).To(Equal(testDataSHA256)) + }) + }) + Describe("CreateSquashFS", Label("CreateSquashFS"), func() { + It("runs with no options if none given", func() { + err := utils.CreateSquashFS(runner, logger, "source", "dest", []string{}) + Expect(runner.IncludesCmds([][]string{ + {"mksquashfs", "source", "dest"}, + })).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) + }) + It("runs with options if given", func() { + err := utils.CreateSquashFS(runner, logger, "source", "dest", constants.GetDefaultSquashfsOptions()) + cmd := []string{"mksquashfs", "source", "dest"} + cmd = append(cmd, constants.GetDefaultSquashfsOptions()...) + Expect(runner.IncludesCmds([][]string{ + cmd, + })).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) + }) + It("returns an error if it fails", func() { + runner.ReturnError = errors.New("error") + err := utils.CreateSquashFS(runner, logger, "source", "dest", []string{}) + Expect(runner.IncludesCmds([][]string{ + {"mksquashfs", "source", "dest"}, + })).To(BeNil()) + Expect(err).To(HaveOccurred()) + }) + }) + Describe("GetUkiCmdline", Label("GetUkiCmdline"), func() { + var defaultCmdline string + BeforeEach(func() { + defaultCmdline = constants.UkiCmdline + " " + constants.UkiCmdlineInstall + }) + + It("returns the default cmdline", func() { + entries := utils.GetUkiCmdline() + Expect(entries[0].Cmdline).To(Equal(defaultCmdline)) + }) + + It("returns the default cmdline with the cmdline flag and install-mode", func() { + viper.Set("extra-cmdline", []string{"key=value testkey"}) + entries := utils.GetUkiCmdline() + cmdlines := []string{} + for _, entry := range entries { + cmdlines = append(cmdlines, entry.Cmdline) + } + Expect(cmdlines).To(ContainElements(defaultCmdline)) + Expect(cmdlines).To(ContainElements(defaultCmdline + " key=value testkey")) + }) + + It("returns more than one cmdline with the cmdline flag if specified multiple values", func() { + viper.Set("extra-cmdline", []string{"key=value testkey", "another=value anotherkey"}) + entries := utils.GetUkiCmdline() + cmdlines := []string{} + for _, entry := range entries { + cmdlines = append(cmdlines, entry.Cmdline) + } + + // Should contain the default one + Expect(cmdlines).To(ContainElements(defaultCmdline)) + // Also the extra ones, without the install-mode + Expect(cmdlines).To(ContainElements(defaultCmdline + " key=value testkey")) + Expect(cmdlines).To(ContainElements(defaultCmdline + " another=value anotherkey")) + }) + + It("expands the default cmdline if extended-cmdline is used", func() { + viper.Set("extend-cmdline", "key=value testkey") + entries := utils.GetUkiCmdline() + for _, entry := range entries { + Expect(entry.Cmdline).To(MatchRegexp(".*key=value testkey")) + } + }) + }) + + Describe("GetUkiSingleCmdlines", Label("GetUkiSingleCmdlines"), func() { + var defaultCmdline string + BeforeEach(func() { + defaultCmdline = constants.UkiCmdline + " " + constants.UkiCmdlineInstall + }) + + It("returns the specified entry", func() { + viper.Set("single-efi-cmdline", []string{"My Entry: key=value"}) + viper.Set("boot-branding", "Kairos") + + entries := utils.GetUkiSingleCmdlines(sdkTypes.NewNullLogger()) + Expect(entries[0].Cmdline).To(MatchRegexp(defaultCmdline + " key=value")) + Expect(entries[0].Title).To(ContainSubstring("Kairos (My Entry)")) + Expect(entries[0].FileName).To(Equal("My_Entry")) + }) + }) +})