From a41bbdb745a2c687a10c61dbbb9577341c006cf7 Mon Sep 17 00:00:00 2001 From: Adphi Date: Tue, 12 Sep 2023 13:59:11 +0200 Subject: [PATCH] add grub-efi support * tests: increase timeout * ci: split e2e tests Signed-off-by: Adphi --- .github/workflows/ci.yaml | 62 +++++++++++++- Dockerfile | 1 - Makefile | 9 ++- README.md | 5 +- bootloader.go | 1 + builder.go | 6 +- cmd/d2vm/flags.go | 14 +++- cmd/d2vm/run/qemu.go | 4 + config_test.go | 45 +++++++++-- convert.go | 4 +- convert_options.go | 8 ++ dockerfile.go | 11 ++- docs/content/reference/d2vm_build.md | 2 +- docs/content/reference/d2vm_convert.md | 2 +- docs/content/reference/d2vm_run_qemu.md | 1 + e2e/e2e_test.go | 57 ++++++++++--- fs.go | 10 +-- grub.go | 77 +++++------------- grub_bios.go | 61 ++++++++++++++ grub_common.go | 102 ++++++++++++++++++++++++ grub_efi.go | 67 ++++++++++++++++ os_release.go | 40 +--------- pkg/qemu/config.go | 7 ++ pkg/qemu/qemu.go | 4 + syslinux.go | 4 + templates/alpine.Dockerfile | 17 ++-- templates/centos.Dockerfile | 21 +++-- templates/debian.Dockerfile | 22 +++-- templates/ubuntu.Dockerfile | 22 +++-- 29 files changed, 524 insertions(+), 162 deletions(-) create mode 100644 grub_bios.go create mode 100644 grub_common.go create mode 100644 grub_efi.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6edf935..5ea2acb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,9 +45,17 @@ jobs: - name: Run tests run: git --no-pager diff --exit-code HEAD~1 HEAD **/**.go templates/ || make tests - e2e-tests: - name: End to end Tests + templates-tests: + name: Test Templates runs-on: ubuntu-latest + strategy: + matrix: + image: + - ubuntu + - debian + - kalilinux + - alpine + - centos steps: - name: Checkout @@ -70,6 +78,53 @@ jobs: - name: Setup dependencies run: sudo apt update && sudo apt install -y util-linux udev parted e2fsprogs mount tar extlinux qemu-utils qemu-system + - name: Share cache with other actions + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + /tmp/.buildx-cache + key: ${{ runner.os }}-tests-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-tests- + + - name: Run tests + run: git --no-pager diff --exit-code HEAD~1 HEAD **/**.go templates/ || IMAGE=${{ matrix.image }} make test-templates + + + e2e-tests: + name: End to end Tests + runs-on: ubuntu-latest + strategy: + matrix: + image: + - alpine:3.17 + - ubuntu:20.04 + - ubuntu:22.04 + - debian:10 + - debian:11 + - centos:8 + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + # fetching all tags is required for the Makefile to compute the right version + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: "1.20" + + - name: Set up QEMU dependency + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Setup dependencies + run: sudo apt update && sudo apt install -y util-linux udev parted e2fsprogs mount tar extlinux qemu-utils qemu-system ovmf + - name: Share cache with other actions uses: actions/cache@v2 with: @@ -81,7 +136,7 @@ jobs: ${{ runner.os }}-tests- - name: Run end-to-end tests - run: make e2e + run: E2E_IMAGES=${{ matrix.image }} make e2e docs-up-to-date: name: Docs up to date @@ -224,6 +279,7 @@ jobs: if: startsWith(github.event.ref, 'refs/tags/v') needs: - tests + - templates-tests - docs-up-to-date - build - e2e-tests diff --git a/Dockerfile b/Dockerfile index bb485a2..db49ef6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,7 +39,6 @@ RUN apt-get update && \ mount \ tar \ extlinux \ - grub2 \ cryptsetup-bin \ qemu-utils && \ apt-get clean && \ diff --git a/Makefile b/Makefile index 9bc3393..2103ce1 100644 --- a/Makefile +++ b/Makefile @@ -64,10 +64,15 @@ docker-run: .PHONY: tests tests: @go generate ./... - @go list .| xargs go test -exec sudo -count=1 -timeout 20m -v + @go list .| xargs go test -exec sudo -count=1 -timeout 60m -v -skip TestConfig + +.PHONY: test-templates +test-templates: + @go generate ./... + @go test -exec sudo -count=1 -timeout 60m -v -run TestConfig/$(IMAGE) e2e: docker-build .build - @go test -v -exec sudo -count=1 -timeout 60m -ldflags "-X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./e2e + @go test -v -exec sudo -count=1 -timeout 60m -ldflags "-X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./e2e -args -images $(E2E_IMAGES) docs-up-to-date: @$(MAKE) cli-docs diff --git a/README.md b/README.md index f743036..c19fc1c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ Obviously, **Distroless** images are not supported. - mount - tar - extlinux (when using syslinux) -- grub2 (when using grub) - qemu-utils - cryptsetup (when using LUKS) - [QEMU](https://www.qemu.org/download/#linux) (optional) @@ -161,7 +160,7 @@ Flags: --append-to-cmdline string Extra kernel cmdline arguments to append to the generated one --boot-fs string Filesystem to use for the boot partition, ext4 or fat32 --boot-size uint Size of the boot partition in MB (default 100) - --bootloader string Bootloader to use: syslinux, grub (default "syslinux") + --bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi (default "syslinux") --force Override output qcow2 image -h, --help help for convert --keep-cache Keep the images after the build @@ -318,7 +317,7 @@ Flags: --append-to-cmdline string Extra kernel cmdline arguments to append to the generated one --boot-fs string Filesystem to use for the boot partition, ext4 or fat32 --boot-size uint Size of the boot partition in MB (default 100) - --bootloader string Bootloader to use: syslinux, grub (default "syslinux") + --bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi (default "syslinux") --build-arg stringArray Set build-time variables -f, --file string Name of the Dockerfile --force Override output qcow2 image diff --git a/bootloader.go b/bootloader.go index afc24ae..4bfeacb 100644 --- a/bootloader.go +++ b/bootloader.go @@ -38,5 +38,6 @@ type BootloaderProvider interface { } type Bootloader interface { + Validate(fs BootFS) error Setup(ctx context.Context, dev, root, cmdline string) error } diff --git a/builder.go b/builder.go index 82d865f..abe57eb 100644 --- a/builder.go +++ b/builder.go @@ -130,7 +130,7 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size uint64, } if bootFS == "" { - bootFS = FSExt4 + bootFS = BootFSExt4 } if err := bootFS.Validate(); err != nil { @@ -146,6 +146,10 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size uint64, return nil, err } + if err := bl.Validate(bootFS); err != nil { + return nil, err + } + if size == 0 { size = 10 * uint64(datasize.GB) } diff --git a/cmd/d2vm/flags.go b/cmd/d2vm/flags.go index 8adcd6d..2f763f7 100644 --- a/cmd/d2vm/flags.go +++ b/cmd/d2vm/flags.go @@ -57,6 +57,18 @@ func validateFlags() error { logrus.Warnf("boot filesystem is set: enabling split boot") splitBoot = true } + efi := bootloader == "grub-efi" || bootloader == "grub" + if efi && !splitBoot { + logrus.Warnf("grub-efi bootloader is set: enabling split boot") + splitBoot = true + } + if efi && bootFS != "" && bootFS != "fat32" { + return fmt.Errorf("grub-efi bootloader only supports fat32 boot filesystem") + } + if efi && bootFS == "" { + logrus.Warnf("grub-efi bootloader is set: enabling fat32 boot filesystem") + bootFS = "fat32" + } if push && tag == "" { return fmt.Errorf("tag is required when pushing container disk image") } @@ -82,7 +94,7 @@ func buildFlags() *pflag.FlagSet { flags.BoolVar(&splitBoot, "split-boot", false, "Split the boot partition from the root partition") flags.Uint64Var(&bootSize, "boot-size", 100, "Size of the boot partition in MB") flags.StringVar(&bootFS, "boot-fs", "", "Filesystem to use for the boot partition, ext4 or fat32") - flags.StringVar(&bootloader, "bootloader", "syslinux", "Bootloader to use: syslinux, grub") + flags.StringVar(&bootloader, "bootloader", "syslinux", "Bootloader to use: syslinux, grub, grub-bios, grub-efi") flags.StringVar(&luksPassword, "luks-password", "", "Password to use for the LUKS encrypted root partition. If not set, the root partition will not be encrypted") flags.BoolVar(&keepCache, "keep-cache", false, "Keep the images after the build") return flags diff --git a/cmd/d2vm/run/qemu.go b/cmd/d2vm/run/qemu.go index 126fdc8..212d7bc 100644 --- a/cmd/d2vm/run/qemu.go +++ b/cmd/d2vm/run/qemu.go @@ -28,6 +28,7 @@ var ( arch string cpus uint mem uint + bios string qemuCmd string qemuDetached bool networking string @@ -71,6 +72,8 @@ func init() { flags.UintVar(&cpus, "cpus", 1, "Number of CPUs") flags.UintVar(&mem, "mem", 1024, "Amount of memory in MB") + flags.StringVar(&bios, "bios", "", "Path to the optional bios binary") + // Backend configuration flags.StringVar(&qemuCmd, "qemu", "", "Path to the qemu binary (otherwise look in $PATH)") flags.BoolVar(&qemuDetached, "detached", false, "Set qemu container to run in the background") @@ -105,6 +108,7 @@ func Qemu(cmd *cobra.Command, args []string) { qemu.WithStdin(os.Stdin), qemu.WithStdout(os.Stdout), qemu.WithStderr(os.Stderr), + qemu.WithBios(bios), } if enableGUI { opts = append(opts, qemu.WithGUI()) diff --git a/config_test.go b/config_test.go index 30e9996..1d6bd96 100644 --- a/config_test.go +++ b/config_test.go @@ -29,19 +29,22 @@ import ( "go.linka.cloud/d2vm/pkg/exec" ) -func testConfig(t *testing.T, ctx context.Context, img string, config Config) { +func testConfig(t *testing.T, ctx context.Context, name, img string, config Config, luks, grubBIOS, grubEFI bool) { require.NoError(t, docker.Pull(ctx, img)) - tmpPath := filepath.Join(os.TempDir(), "d2vm-tests", strings.NewReplacer(":", "-", ".", "-").Replace(img)) + tmpPath := filepath.Join(os.TempDir(), "d2vm-tests", strings.NewReplacer(":", "-", ".", "-").Replace(name)) require.NoError(t, os.MkdirAll(tmpPath, 0755)) defer os.RemoveAll(tmpPath) logrus.Infof("inspecting image %s", img) - r, err := FetchDockerImageOSRelease(ctx, img, tmpPath) + r, err := FetchDockerImageOSRelease(ctx, img) require.NoError(t, err) defer docker.Remove(ctx, img) - d, err := NewDockerfile(r, img, "root", "", false, false) + if !r.SupportsLUKS() && luks { + t.Skipf("LUKS not supported for %s", r.Version) + } + d, err := NewDockerfile(r, img, "root", "", luks, grubBIOS, grubEFI) require.NoError(t, err) logrus.Infof("docker image based on %s", d.Release.Name) - p := filepath.Join(tmpPath, docker.FormatImgName(img)) + p := filepath.Join(tmpPath, docker.FormatImgName(name)) dir := filepath.Dir(p) f, err := os.Create(p) require.NoError(t, err) @@ -51,6 +54,10 @@ func testConfig(t *testing.T, ctx context.Context, img string, config Config) { logrus.Infof("building kernel enabled image") require.NoError(t, docker.Build(ctx, imgUUID, p, dir)) defer docker.Remove(ctx, imgUUID) + // we don't need to test the kernel location if grub is enabled + if grubBIOS || grubEFI { + return + } require.NoError(t, docker.RunAndRemove(ctx, imgUUID, "test", "-f", config.Kernel)) require.NoError(t, docker.RunAndRemove(ctx, imgUUID, "test", "-f", config.Initrd)) } @@ -116,13 +123,35 @@ func TestConfig(t *testing.T) { } exec.SetDebug(true) + names := []string{"luks", "grub-bios", "grub-efi"} + bools := []bool{false, true} + for _, test := range tests { test := test t.Run(test.image, func(t *testing.T) { t.Parallel() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - testConfig(t, ctx, test.image, test.config) + for _, luks := range bools { + for _, grubBIOS := range bools { + for _, grubEFI := range bools { + luks := luks + grubBIOS := grubBIOS + grubEFI := grubEFI + n := []string{test.image} + for i, v := range []bool{luks, grubBIOS, grubEFI} { + if v { + n = append(n, names[i]) + } + } + name := strings.Join(n, "-") + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + testConfig(t, ctx, name, test.image, test.config, luks, grubBIOS, grubEFI) + }) + } + } + } }) } } diff --git a/convert.go b/convert.go index 0651fe8..b056892 100644 --- a/convert.go +++ b/convert.go @@ -41,7 +41,7 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error { defer os.RemoveAll(tmpPath) logrus.Infof("inspecting image %s", img) - r, err := FetchDockerImageOSRelease(ctx, img, tmpPath) + r, err := FetchDockerImageOSRelease(ctx, img) if err != nil { return err } @@ -51,7 +51,7 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error { } if !o.raw { - d, err := NewDockerfile(r, img, o.password, o.networkManager, o.luksPassword != "", o.bootLoader == "grub") + d, err := NewDockerfile(r, img, o.password, o.networkManager, o.luksPassword != "", o.hasGrubBIOS(), o.hasGrubEFI()) if err != nil { return err } diff --git a/convert_options.go b/convert_options.go index 1682590..74000be 100644 --- a/convert_options.go +++ b/convert_options.go @@ -34,6 +34,14 @@ type convertOptions struct { keepCache bool } +func (o *convertOptions) hasGrubBIOS() bool { + return o.bootLoader == "grub" || o.bootLoader == "grub-bios" +} + +func (o *convertOptions) hasGrubEFI() bool { + return o.bootLoader == "grub" || o.bootLoader == "grub-efi" +} + func WithSize(size uint64) ConvertOption { return func(o *convertOptions) { o.size = size diff --git a/dockerfile.go b/dockerfile.go index 306f412..7657168 100644 --- a/dockerfile.go +++ b/dockerfile.go @@ -65,16 +65,21 @@ type Dockerfile struct { Release OSRelease NetworkManager NetworkManager Luks bool - Grub bool + GrubBIOS bool + GrubEFI bool tmpl *template.Template } +func (d Dockerfile) Grub() bool { + return d.GrubBIOS || d.GrubEFI +} + func (d Dockerfile) Render(w io.Writer) error { return d.tmpl.Execute(w, d) } -func NewDockerfile(release OSRelease, img, password string, networkManager NetworkManager, luks, grub bool) (Dockerfile, error) { - d := Dockerfile{Release: release, Image: img, Password: password, NetworkManager: networkManager, Luks: luks, Grub: grub} +func NewDockerfile(release OSRelease, img, password string, networkManager NetworkManager, luks, grubBIOS, grubEFI bool) (Dockerfile, error) { + d := Dockerfile{Release: release, Image: img, Password: password, NetworkManager: networkManager, Luks: luks, GrubBIOS: grubBIOS, GrubEFI: grubEFI} var net NetworkManager switch release.ID { case ReleaseDebian: diff --git a/docs/content/reference/d2vm_build.md b/docs/content/reference/d2vm_build.md index 9524dab..c56737d 100644 --- a/docs/content/reference/d2vm_build.md +++ b/docs/content/reference/d2vm_build.md @@ -12,7 +12,7 @@ d2vm build [context directory] [flags] --append-to-cmdline string Extra kernel cmdline arguments to append to the generated one --boot-fs string Filesystem to use for the boot partition, ext4 or fat32 --boot-size uint Size of the boot partition in MB (default 100) - --bootloader string Bootloader to use: syslinux, grub (default "syslinux") + --bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi (default "syslinux") --build-arg stringArray Set build-time variables -f, --file string Name of the Dockerfile --force Override output qcow2 image diff --git a/docs/content/reference/d2vm_convert.md b/docs/content/reference/d2vm_convert.md index 62fbe45..8363c24 100644 --- a/docs/content/reference/d2vm_convert.md +++ b/docs/content/reference/d2vm_convert.md @@ -12,7 +12,7 @@ d2vm convert [docker image] [flags] --append-to-cmdline string Extra kernel cmdline arguments to append to the generated one --boot-fs string Filesystem to use for the boot partition, ext4 or fat32 --boot-size uint Size of the boot partition in MB (default 100) - --bootloader string Bootloader to use: syslinux, grub (default "syslinux") + --bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi (default "syslinux") --force Override output qcow2 image -h, --help help for convert --keep-cache Keep the images after the build diff --git a/docs/content/reference/d2vm_run_qemu.md b/docs/content/reference/d2vm_run_qemu.md index 441a547..51f220e 100644 --- a/docs/content/reference/d2vm_run_qemu.md +++ b/docs/content/reference/d2vm_run_qemu.md @@ -11,6 +11,7 @@ d2vm run qemu [options] [image-path] [flags] ``` --accel string Choose acceleration mode. Use 'tcg' to disable it. (default "kvm:tcg") --arch string Type of architecture to use, e.g. x86_64, aarch64, s390x (default "x86_64") + --bios string Path to the optional bios binary --cpus uint Number of CPUs (default 1) --detached Set qemu container to run in the background --disk disk Disk config, may be repeated. [file=]path[,size=1G][,format=qcow2] (default []) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index d1e44e0..dbcf0e1 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,6 +18,7 @@ import ( "bufio" "bytes" "context" + "flag" "io" "os" "path/filepath" @@ -36,6 +37,7 @@ import ( type test struct { name string args []string + efi bool } type img struct { @@ -43,14 +45,24 @@ type img struct { luks string } -var images = []img{ - {name: "alpine:3.17", luks: "Enter passphrase for /dev/sda2:"}, - {name: "ubuntu:20.04", luks: "Please unlock disk root:"}, - {name: "ubuntu:22.04", luks: "Please unlock disk root:"}, - {name: "debian:10", luks: "Please unlock disk root:"}, - {name: "debian:11", luks: "Please unlock disk root:"}, - {name: "centos:8", luks: "Please enter passphrase for disk"}, -} +var ( + images = []img{ + {name: "alpine:3.17", luks: "Enter passphrase for /dev/sda2:"}, + {name: "ubuntu:20.04", luks: "Please unlock disk root:"}, + {name: "ubuntu:22.04", luks: "Please unlock disk root:"}, + {name: "debian:10", luks: "Please unlock disk root:"}, + {name: "debian:11", luks: "Please unlock disk root:"}, + {name: "centos:8", luks: "Please enter passphrase for disk"}, + } + imgNames = func() []string { + var imgs []string + for _, img := range images { + imgs = append(imgs, img.name) + } + return imgs + }() + imgs = flag.String("images", "", "comma separated list of images to test, must be one of: "+strings.Join(imgNames, ",")) +) func TestConvert(t *testing.T) { require := require2.New(t) @@ -62,6 +74,10 @@ func TestConvert(t *testing.T) { name: "split-boot", args: []string{"--split-boot"}, }, + { + name: "fat32", + args: []string{"--split-boot", "--boot-fs=fat32"}, + }, { name: "luks", args: []string{"--luks-password=root"}, @@ -69,13 +85,30 @@ func TestConvert(t *testing.T) { { name: "grub", args: []string{"--bootloader=grub"}, + efi: true, }, { name: "grub-luks", args: []string{"--bootloader=grub", "--luks-password=root"}, + efi: true, }, } + var testImgs []img +imgs: + for _, v := range strings.Split(*imgs, ",") { + for _, img := range images { + if img.name == v { + testImgs = append(testImgs, img) + continue imgs + } + } + t.Fatalf("invalid image: %q, valid images: %s", v, strings.Join(imgNames, ",")) + } + if len(testImgs) == 0 { + testImgs = images + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -83,7 +116,7 @@ func TestConvert(t *testing.T) { require.NoError(os.MkdirAll(dir, os.ModePerm)) defer os.RemoveAll(dir) - for _, img := range images { + for _, img := range testImgs { t.Run(img.name, func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -161,7 +194,11 @@ func TestConvert(t *testing.T) { cancel() } }() - if err := qemu.Run(ctx, out, qemu.WithStdin(inr), qemu.WithStdout(io.MultiWriter(outw, os.Stdout)), qemu.WithStderr(io.Discard), qemu.WithMemory(2048)); err != nil && !success.Load() { + opts := []qemu.Option{qemu.WithStdin(inr), qemu.WithStdout(io.MultiWriter(outw, os.Stdout)), qemu.WithStderr(io.Discard), qemu.WithMemory(2048), qemu.WithCPUs(2)} + if tt.efi { + opts = append(opts, qemu.WithBios("/usr/share/ovmf/OVMF.fd")) + } + if err := qemu.Run(ctx, out, opts...); err != nil && !success.Load() { t.Fatalf("failed to run qemu: %v", err) } }) diff --git a/fs.go b/fs.go index 33cc58f..0752e1d 100644 --- a/fs.go +++ b/fs.go @@ -21,8 +21,8 @@ import ( type BootFS string const ( - FSExt4 BootFS = "ext4" - FSFat32 BootFS = "fat32" + BootFSExt4 BootFS = "ext4" + BootFSFat32 BootFS = "fat32" ) func (f BootFS) String() string { @@ -30,11 +30,11 @@ func (f BootFS) String() string { } func (f BootFS) IsExt() bool { - return f == FSExt4 + return f == BootFSExt4 } func (f BootFS) IsFat() bool { - return f == FSFat32 + return f == BootFSFat32 } func (f BootFS) IsSupported() bool { @@ -50,7 +50,7 @@ func (f BootFS) Validate() error { func (f BootFS) linux() string { switch f { - case FSFat32: + case BootFSFat32: return "vfat" default: return "ext4" diff --git a/grub.go b/grub.go index 86d7095..da20edd 100644 --- a/grub.go +++ b/grub.go @@ -17,89 +17,54 @@ package d2vm import ( "context" "fmt" - "os" - exec2 "os/exec" - "path/filepath" "github.com/sirupsen/logrus" - - "go.linka.cloud/d2vm/pkg/exec" ) -const grubCfg = `GRUB_DEFAULT=0 -GRUB_HIDDEN_TIMEOUT=0 -GRUB_HIDDEN_TIMEOUT_QUIET=true -GRUB_TIMEOUT=0 -GRUB_CMDLINE_LINUX_DEFAULT="%s" -GRUB_CMDLINE_LINUX="" -GRUB_TERMINAL=console -` - type grub struct { - name string - c Config - r OSRelease + *grubCommon +} + +func (g grub) Validate(fs BootFS) error { + switch fs { + case BootFSFat32: + return nil + default: + return fmt.Errorf("grub only supports fat32 boot filesystem due to grub-efi") + } } func (g grub) Setup(ctx context.Context, dev, root string, cmdline string) error { logrus.Infof("setting up grub bootloader") - if err := os.WriteFile(filepath.Join(root, "etc", "default", "grub"), []byte(fmt.Sprintf(grubCfg, cmdline)), perm); err != nil { + clean, err := g.prepare(ctx, dev, root, cmdline) + if err != nil { return err } - if err := os.MkdirAll(filepath.Join(root, "boot", g.name), os.ModePerm); err != nil { + defer clean() + if err := g.install(ctx, "--target=x86_64-efi", "--efi-directory=/boot", "--no-nvram", "--removable", "--no-floppy"); err != nil { return err } - mounts := []string{"dev", "proc", "sys"} - var unmounts []string - defer func() { - for _, v := range unmounts { - if err := exec.Run(ctx, "umount", filepath.Join(root, v)); err != nil { - logrus.Errorf("failed to unmount /%s: %s", v, err) - } - } - }() - for _, v := range mounts { - if err := exec.Run(ctx, "mount", "-o", "bind", "/"+v, filepath.Join(root, v)); err != nil { - return err - } - unmounts = append(unmounts, v) - } - - if err := exec.Run(ctx, "chroot", root, g.name+"-install", "--target=i386-pc", "--boot-directory", "/boot", dev); err != nil { + if err := g.install(ctx, "--target=i386-pc", "--boot-directory=/boot", dev); err != nil { return err } - if err := exec.Run(ctx, "chroot", root, g.name+"-mkconfig", "-o", "/boot/"+g.name+"/grub.cfg"); err != nil { + if err := g.mkconfig(ctx); err != nil { return err } return nil } -type grubBootloaderProvider struct { +type grubProvider struct { config Config } -func (g grubBootloaderProvider) New(c Config, r OSRelease) (Bootloader, error) { - name := "grub" - if r.ID == "centos" { - name = "grub2" - } - if _, err := exec2.LookPath("grub-install"); err != nil { - return nil, err - } - if _, err := exec2.LookPath("grub-mkconfig"); err != nil { - return nil, err - } - return grub{ - name: name, - c: c, - r: r, - }, nil +func (g grubProvider) New(c Config, r OSRelease) (Bootloader, error) { + return grub{grubCommon: newGrubCommon(c, r)}, nil } -func (g grubBootloaderProvider) Name() string { +func (g grubProvider) Name() string { return "grub" } func init() { - RegisterBootloaderProvider(grubBootloaderProvider{}) + RegisterBootloaderProvider(grubProvider{}) } diff --git a/grub_bios.go b/grub_bios.go new file mode 100644 index 0000000..ccc5c02 --- /dev/null +++ b/grub_bios.go @@ -0,0 +1,61 @@ +// Copyright 2023 Linka Cloud All rights reserved. +// +// 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 d2vm + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +type grubBios struct { + *grubCommon +} + +func (g grubBios) Validate(_ BootFS) error { + return nil +} + +func (g grubBios) Setup(ctx context.Context, dev, root string, cmdline string) error { + logrus.Infof("setting up grub bootloader") + clean, err := g.prepare(ctx, dev, root, cmdline) + if err != nil { + return err + } + defer clean() + if err := g.install(ctx, "--target=i386-pc", "--boot-directory=/boot", dev); err != nil { + return err + } + if err := g.mkconfig(ctx); err != nil { + return err + } + return nil +} + +type grubBiosProvider struct { + config Config +} + +func (g grubBiosProvider) New(c Config, r OSRelease) (Bootloader, error) { + return grubBios{grubCommon: newGrubCommon(c, r)}, nil +} + +func (g grubBiosProvider) Name() string { + return "grub-bios" +} + +func init() { + RegisterBootloaderProvider(grubBiosProvider{}) +} diff --git a/grub_common.go b/grub_common.go new file mode 100644 index 0000000..6e41095 --- /dev/null +++ b/grub_common.go @@ -0,0 +1,102 @@ +// Copyright 2023 Linka Cloud All rights reserved. +// +// 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 d2vm + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" + + "go.linka.cloud/d2vm/pkg/exec" +) + +const grubCfg = `GRUB_DEFAULT=0 +GRUB_HIDDEN_TIMEOUT=0 +GRUB_HIDDEN_TIMEOUT_QUIET=true +GRUB_TIMEOUT=0 +GRUB_CMDLINE_LINUX_DEFAULT="%s" +GRUB_CMDLINE_LINUX="" +GRUB_TERMINAL=console +` + +type grubCommon struct { + name string + c Config + r OSRelease + root string + dev string +} + +func newGrubCommon(c Config, r OSRelease) *grubCommon { + name := "grub" + if r.ID == "centos" { + name = "grub2" + } + return &grubCommon{ + name: name, + c: c, + r: r, + } +} + +func (g *grubCommon) prepare(ctx context.Context, dev, root, cmdline string) (clean func(), err error) { + g.dev = dev + g.root = root + if err = os.WriteFile(filepath.Join(root, "etc", "default", "grub"), []byte(fmt.Sprintf(grubCfg, cmdline)), perm); err != nil { + return + } + if err = os.MkdirAll(filepath.Join(root, "boot", g.name), os.ModePerm); err != nil { + return + } + mounts := []string{"dev", "proc", "sys"} + var unmounts []string + clean = func() { + for _, v := range unmounts { + if err := exec.Run(ctx, "umount", filepath.Join(root, v)); err != nil { + logrus.Errorf("failed to unmount /%s: %s", v, err) + } + } + } + defer func() { + if err != nil { + clean() + } + }() + for _, v := range mounts { + if err = exec.Run(ctx, "mount", "-o", "bind", "/"+v, filepath.Join(root, v)); err != nil { + return + } + unmounts = append(unmounts, v) + } + return +} + +func (g *grubCommon) install(ctx context.Context, args ...string) error { + if g.dev == "" || g.root == "" { + return fmt.Errorf("grubCommon not prepared") + } + args = append([]string{g.root, g.name + "-install"}, args...) + return exec.Run(ctx, "chroot", args...) +} + +func (g *grubCommon) mkconfig(ctx context.Context) error { + if g.dev == "" || g.root == "" { + return fmt.Errorf("grubCommon not prepared") + } + return exec.Run(ctx, "chroot", g.root, g.name+"-mkconfig", "-o", "/boot/"+g.name+"/grub.cfg") +} diff --git a/grub_efi.go b/grub_efi.go new file mode 100644 index 0000000..75bd43a --- /dev/null +++ b/grub_efi.go @@ -0,0 +1,67 @@ +// Copyright 2023 Linka Cloud All rights reserved. +// +// 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 d2vm + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" +) + +type grubEFI struct { + *grubCommon +} + +func (g grubEFI) Validate(fs BootFS) error { + switch fs { + case BootFSFat32: + return nil + default: + return fmt.Errorf("grub-efi only supports fat32 boot filesystem") + } +} + +func (g grubEFI) Setup(ctx context.Context, dev, root string, cmdline string) error { + logrus.Infof("setting up grub-efi bootloader") + clean, err := g.prepare(ctx, dev, root, cmdline) + if err != nil { + return err + } + defer clean() + if err := g.install(ctx, "--target=x86_64-efi", "--efi-directory=/boot", "--no-nvram", "--removable", "--no-floppy"); err != nil { + return err + } + if err := g.mkconfig(ctx); err != nil { + return err + } + return nil +} + +type grubEFIProvider struct { + config Config +} + +func (g grubEFIProvider) New(c Config, r OSRelease) (Bootloader, error) { + return grubEFI{grubCommon: newGrubCommon(c, r)}, nil +} + +func (g grubEFIProvider) Name() string { + return "grub-efi" +} + +func init() { + RegisterBootloaderProvider(grubEFIProvider{}) +} diff --git a/os_release.go b/os_release.go index 3374bb5..70a454b 100644 --- a/os_release.go +++ b/os_release.go @@ -16,12 +16,8 @@ package d2vm import ( "context" - "fmt" - "os" - "path/filepath" "strconv" "strings" - "text/template" "github.com/joho/godotenv" "github.com/sirupsen/logrus" @@ -107,40 +103,8 @@ func ParseOSRelease(s string) (OSRelease, error) { return o, nil } -const ( - osReleaseDockerfile = ` -FROM {{ . }} - -ENTRYPOINT [""] - -CMD ["/bin/cat", "/etc/os-release"] -` -) - -var ( - osReleaseDockerfileTemplate = template.Must(template.New("osrelease.Dockerfile").Parse(osReleaseDockerfile)) -) - -func FetchDockerImageOSRelease(ctx context.Context, img string, tmpPath string) (OSRelease, error) { - d := filepath.Join(tmpPath, "osrelease.Dockerfile") - f, err := os.Create(d) - if err != nil { - return OSRelease{}, err - } - defer f.Close() - if err := osReleaseDockerfileTemplate.Execute(f, img); err != nil { - return OSRelease{}, err - } - imgTag := fmt.Sprintf("os-release-%s", img) - if err := docker.Cmd(ctx, "image", "build", "-t", imgTag, "-f", d, tmpPath); err != nil { - return OSRelease{}, err - } - defer func() { - if err := docker.Cmd(ctx, "image", "rm", imgTag); err != nil { - logrus.WithError(err).Error("failed to cleanup OSRelease Docker Image") - } - }() - o, _, err := docker.CmdOut(ctx, "run", "--rm", "-i", imgTag) +func FetchDockerImageOSRelease(ctx context.Context, img string) (OSRelease, error) { + o, _, err := docker.CmdOut(ctx, "run", "--rm", "-i", "--entrypoint", "cat", img, "/etc/os-release") if err != nil { return OSRelease{}, err } diff --git a/pkg/qemu/config.go b/pkg/qemu/config.go index bfa73d4..45862e0 100644 --- a/pkg/qemu/config.go +++ b/pkg/qemu/config.go @@ -44,6 +44,7 @@ type config struct { arch string cpus uint memory uint + bios string accel string detached bool qemuBinPath string @@ -92,6 +93,12 @@ func WithMemory(memory uint) Option { } } +func WithBios(bios string) Option { + return func(c *config) { + c.bios = bios + } +} + func WithAccel(accel string) Option { return func(c *config) { c.accel = accel diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go index 289e2a7..489b0df 100644 --- a/pkg/qemu/qemu.go +++ b/pkg/qemu/qemu.go @@ -197,6 +197,10 @@ func (c *config) buildQemuCmdline() ([]string, error) { qemuArgs = append(qemuArgs, "-m", fmt.Sprintf("%d", c.memory)) qemuArgs = append(qemuArgs, "-uuid", c.uuid.String()) + if c.bios != "" { + qemuArgs = append(qemuArgs, "-bios", c.bios) + } + // Need to specify the vcpu type when running qemu on arm64 platform, for security reason, // the vcpu should be "host" instead of other names such as "cortex-a53"... if c.arch == "aarch64" { diff --git a/syslinux.go b/syslinux.go index 0b027fc..59fcbc0 100644 --- a/syslinux.go +++ b/syslinux.go @@ -50,6 +50,10 @@ type syslinux struct { mbrBin string } +func (s syslinux) Validate(_ BootFS) error { + return nil +} + func (s syslinux) Setup(ctx context.Context, dev, root string, cmdline string) error { logrus.Infof("setting up syslinux bootloader") if err := exec.Run(ctx, "extlinux", "--install", filepath.Join(root, "boot")); err != nil { diff --git a/templates/alpine.Dockerfile b/templates/alpine.Dockerfile index 4a8757d..fb03bdf 100644 --- a/templates/alpine.Dockerfile +++ b/templates/alpine.Dockerfile @@ -2,8 +2,7 @@ FROM {{ .Image }} USER root -RUN apk update --no-cache && \ - apk add \ +RUN apk add --no-cache \ util-linux \ linux-virt \ {{- if ge .Release.VersionID "3.17" }} @@ -31,13 +30,21 @@ iface eth0 inet dhcp\n\ ' > /etc/network/interfaces {{ end }} -{{- if .Luks }} +{{ if .Luks }} RUN apk add --no-cache cryptsetup && \ source /etc/mkinitfs/mkinitfs.conf && \ echo "features=\"${features} cryptsetup\"" > /etc/mkinitfs/mkinitfs.conf && \ mkinitfs $(ls /lib/modules) {{- end }} -{{- if .Grub }} \ -RUN apk add --no-cache grub grub-bios +# we need to keep that at the end, because after it, we can't install packages without error anymore due to grub hooks +{{- if .Grub }} +RUN apk add --no-cache \ +{{- if .GrubBIOS }} + grub-bios \ +{{- end }} +{{- if .GrubEFI }} + grub-efi \ +{{- end }} + grub {{- end }} diff --git a/templates/centos.Dockerfile b/templates/centos.Dockerfile index d06ca51..a7ec18a 100644 --- a/templates/centos.Dockerfile +++ b/templates/centos.Dockerfile @@ -7,24 +7,23 @@ RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-* && \ RUN yum update -y +# See https://bugzilla.redhat.com/show_bug.cgi?id=1917213 RUN yum install -y \ kernel \ systemd \ NetworkManager \ -{{- if .Grub }} +{{- if .GrubBIOS }} grub2 \ +{{- end }} +{{- if .GrubEFI }} + grub2 grub2-efi-x64 grub2-efi-x64-modules \ {{- end }} e2fsprogs \ sudo && \ systemctl enable NetworkManager && \ systemctl unmask systemd-remount-fs.service && \ - systemctl unmask getty.target - -{{- if not .Grub }} -RUN cd /boot && \ - mv $(find . -name 'vmlinuz-*') /boot/vmlinuz && \ - mv $(find . -name 'initramfs-*.img') /boot/initrd.img -{{- end }} + systemctl unmask getty.target && \ + find /boot -type l -exec rm {} \; {{ if .Luks }} RUN yum install -y cryptsetup && \ @@ -34,3 +33,9 @@ RUN dracut --no-hostonly --regenerate-all --force {{ end }} {{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }} + +{{- if not .Grub }} +RUN cd /boot && \ + mv $(find . -name 'vmlinuz-*') /boot/vmlinuz && \ + mv $(find . -name 'initramfs-*.img') /boot/initrd.img +{{- end }} diff --git a/templates/debian.Dockerfile b/templates/debian.Dockerfile index 67df837..85fa5b6 100644 --- a/templates/debian.Dockerfile +++ b/templates/debian.Dockerfile @@ -10,21 +10,23 @@ RUN echo "deb http://archive.debian.org/debian stretch main" > /etc/apt/sources. echo "deb-src http://archive.debian.org/debian-security stretch/updates main" >> /etc/apt/sources.list {{- end }} -RUN apt-get -y update && \ +RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \ linux-image-amd64 && \ find /boot -type l -exec rm {} \; -{{- if not .Grub }} -RUN mv $(find /boot -name 'vmlinuz-*') /boot/vmlinuz && \ - mv $(find /boot -name 'initrd.img-*') /boot/initrd.img -{{- end }} - RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ systemd-sysv \ systemd \ {{- if .Grub }} - grub2 \ + grub-common \ + grub2-common \ + {{- end }} + {{- if .GrubBIOS }} + grub-pc-bin \ + {{- end }} + {{- if .GrubEFI }} + grub-efi-amd64-bin \ {{- end }} dbus \ iproute2 \ @@ -65,3 +67,9 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cr echo "CRYPTSETUP=y" >> /etc/cryptsetup-initramfs/conf-hook && \ update-initramfs -u -v {{- end }} + +# needs to be after update-initramfs +{{- if not .Grub }} +RUN mv $(find /boot -name 'vmlinuz-*') /boot/vmlinuz && \ + mv $(find /boot -name 'initrd.img-*') /boot/initrd.img +{{- end }} diff --git a/templates/ubuntu.Dockerfile b/templates/ubuntu.Dockerfile index b2b7082..28e14f2 100644 --- a/templates/ubuntu.Dockerfile +++ b/templates/ubuntu.Dockerfile @@ -2,14 +2,21 @@ FROM {{ .Image }} USER root -RUN apt-get update -y && \ +RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \ linux-image-virtual \ initramfs-tools \ systemd-sysv \ systemd \ {{- if .Grub }} - grub2 \ + grub-common \ + grub2-common \ +{{- end }} +{{- if .GrubBIOS }} + grub-pc-bin \ +{{- end }} +{{- if .GrubEFI }} + grub-efi-amd64-bin \ {{- end }} dbus \ isc-dhcp-client \ @@ -17,11 +24,6 @@ RUN apt-get update -y && \ iputils-ping && \ find /boot -type l -exec rm {} \; -{{- if not .Grub }} -RUN mv $(find /boot -name 'vmlinuz-*') /boot/vmlinuz && \ - mv $(find /boot -name 'initrd.img-*') /boot/initrd.img -{{- end }} - RUN systemctl preset-all {{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }} @@ -54,3 +56,9 @@ iface eth0 inet dhcp\n\ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cryptsetup-initramfs && \ update-initramfs -u -v {{- end }} + +# needs to be after update-initramfs +{{- if not .Grub }} +RUN mv $(find /boot -name 'vmlinuz-*') /boot/vmlinuz && \ + mv $(find /boot -name 'initrd.img-*') /boot/initrd.img +{{- end }}