| // Copyright 2020 The Monogon Project Authors. |
| // |
| // SPDX-License-Identifier: Apache-2.0 |
| // |
| // 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. |
| |
| // This package runs the installer image in a VM provided with an empty block |
| // device. It then examines the installer console output and the blok device to |
| // determine whether the installation process completed without issue. |
| package installer |
| |
| import ( |
| "bytes" |
| "context" |
| "fmt" |
| "log" |
| "os" |
| "path/filepath" |
| "syscall" |
| "testing" |
| |
| diskfs "github.com/diskfs/go-diskfs" |
| "github.com/diskfs/go-diskfs/disk" |
| "github.com/diskfs/go-diskfs/partition/gpt" |
| |
| mctl "source.monogon.dev/metropolis/cli/metroctl/core" |
| "source.monogon.dev/metropolis/cli/pkg/datafile" |
| "source.monogon.dev/metropolis/node/build/mkimage/osimage" |
| "source.monogon.dev/metropolis/pkg/cmd" |
| "source.monogon.dev/metropolis/proto/api" |
| ) |
| |
| // Each variable in this block points to either a test dependency or a side |
| // effect. These variables are initialized in TestMain using Bazel. |
| var ( |
| // installerImage is a filesystem path pointing at the installer image that |
| // is generated during the test, and is removed afterwards. |
| installerImage string |
| // nodeStorage is a filesystem path pointing at the VM block device image |
| // Metropolis is installed to during the test. The file is removed afterwards. |
| nodeStorage string |
| ) |
| |
| // runQemu starts a new QEMU process, expecting the given output to appear |
| // in any line printed. It returns true, if the expected string was found, |
| // and false otherwise. |
| // |
| // QEMU is killed shortly after the string is found, or when the context is |
| // cancelled. |
| func runQemu(ctx context.Context, args []string, expectedOutput string) (bool, error) { |
| defaultArgs := []string{ |
| "-machine", "q35", "-accel", "kvm", "-nographic", "-nodefaults", |
| "-m", "512", |
| "-smp", "2", |
| "-cpu", "host", |
| "-drive", "if=pflash,format=raw,readonly,file=external/edk2/OVMF_CODE.fd", |
| "-drive", "if=pflash,format=raw,snapshot=on,file=external/edk2/OVMF_VARS.fd", |
| "-serial", "stdio", |
| "-no-reboot", |
| } |
| qemuArgs := append(defaultArgs, args...) |
| return cmd.RunCommand(ctx, "external/qemu/qemu-x86_64-softmmu", qemuArgs, expectedOutput) |
| } |
| |
| // runQemuWithInstaller runs the Metropolis Installer in a qemu, performing the |
| // same search-through-std{out,err} as runQemu. |
| func runQemuWithInstaller(ctx context.Context, args []string, expectedOutput string) (bool, error) { |
| args = append(args, "-drive", "if=virtio,format=raw,snapshot=on,cache=unsafe,file="+installerImage) |
| return runQemu(ctx, args, expectedOutput) |
| } |
| |
| // getStorage creates a sparse file, given a size expressed in mebibytes, and |
| // returns a path to that file. It may return an error. |
| func getStorage(size int64) (string, error) { |
| image, err := os.Create(nodeStorage) |
| if err != nil { |
| return "", fmt.Errorf("couldn't create the block device image at %q: %w", nodeStorage, err) |
| } |
| if err := syscall.Ftruncate(int(image.Fd()), size*1024*1024); err != nil { |
| return "", fmt.Errorf("couldn't resize the block device image at %q: %w", nodeStorage, err) |
| } |
| image.Close() |
| return nodeStorage, nil |
| } |
| |
| // qemuDriveParam returns QEMU parameters required to run it with a |
| // raw-format image at path. |
| func qemuDriveParam(path string) []string { |
| return []string{"-drive", "if=virtio,format=raw,snapshot=off,cache=unsafe,file=" + path} |
| } |
| |
| // checkEspContents verifies the presence of the EFI payload inside of image's |
| // first partition. It returns nil on success. |
| func checkEspContents(image *disk.Disk) error { |
| // Get the ESP. |
| fs, err := image.GetFilesystem(1) |
| if err != nil { |
| return fmt.Errorf("couldn't read the installer ESP: %w", err) |
| } |
| // Make sure the EFI payload exists by attempting to open it. |
| efiPayload, err := fs.OpenFile(osimage.EFIPayloadPath, os.O_RDONLY) |
| if err != nil { |
| return fmt.Errorf("couldn't open the installer's EFI Payload at %q: %w", osimage.EFIPayloadPath, err) |
| } |
| efiPayload.Close() |
| return nil |
| } |
| |
| func TestMain(m *testing.M) { |
| installerImage = filepath.Join(os.Getenv("TEST_TMPDIR"), "installer.img") |
| nodeStorage = filepath.Join(os.Getenv("TEST_TMPDIR"), "stor.img") |
| |
| installer := datafile.MustGet("metropolis/installer/test/kernel.efi") |
| bundle := datafile.MustGet("metropolis/installer/test/testos/testos_bundle.zip") |
| iargs := mctl.MakeInstallerImageArgs{ |
| Installer: bytes.NewBuffer(installer), |
| InstallerSize: uint64(len(installer)), |
| TargetPath: installerImage, |
| NodeParams: &api.NodeParameters{}, |
| Bundle: bytes.NewBuffer(bundle), |
| BundleSize: uint64(len(bundle)), |
| } |
| if err := mctl.MakeInstallerImage(iargs); err != nil { |
| log.Fatalf("Couldn't create the installer image at %q: %v", installerImage, err) |
| } |
| // With common dependencies set up, run the tests. |
| code := m.Run() |
| // Clean up. |
| os.Remove(installerImage) |
| os.Exit(code) |
| } |
| |
| func TestInstallerImage(t *testing.T) { |
| // This test examines the installer image, making sure that the GPT and the |
| // ESP contents are in order. |
| image, err := diskfs.OpenWithMode(installerImage, diskfs.ReadOnly) |
| if err != nil { |
| t.Errorf("Couldn't open the installer image at %q: %s", installerImage, err.Error()) |
| } |
| // Verify that GPT exists. |
| ti, err := image.GetPartitionTable() |
| if ti.Type() != "gpt" { |
| t.Error("Couldn't verify that the installer image contains a GPT.") |
| } |
| // Check that the first partition is likely to be a valid ESP. |
| pi := ti.GetPartitions() |
| esp := (pi[0]).(*gpt.Partition) |
| if esp.Start == 0 || esp.End == 0 { |
| t.Error("The installer's ESP GPT entry looks off.") |
| } |
| // Verify that the image contains only one partition. |
| second := (pi[1]).(*gpt.Partition) |
| if second.Name != "" || second.Start != 0 || second.End != 0 { |
| t.Error("It appears the installer image contains more than one partition.") |
| } |
| // Verify the ESP contents. |
| if err := checkEspContents(image); err != nil { |
| t.Error(err.Error()) |
| } |
| } |
| |
| func TestNoBlockDevices(t *testing.T) { |
| ctx, ctxC := context.WithCancel(context.Background()) |
| defer ctxC() |
| |
| // No block devices are passed to QEMU aside from the install medium. Expect |
| // the installer to fail at the device probe stage rather than attempting to |
| // use the medium as the target device. |
| expectedOutput := "Couldn't find a suitable block device" |
| result, err := runQemuWithInstaller(ctx, nil, expectedOutput) |
| if err != nil { |
| t.Error(err.Error()) |
| } |
| if result != true { |
| t.Errorf("QEMU didn't produce the expected output %q", expectedOutput) |
| } |
| } |
| |
| func TestBlockDeviceTooSmall(t *testing.T) { |
| ctx, ctxC := context.WithCancel(context.Background()) |
| defer ctxC() |
| |
| // Prepare the block device the installer will install to. This time the |
| // target device is too small to host a Metropolis installation. |
| imagePath, err := getStorage(64) |
| defer os.Remove(imagePath) |
| if err != nil { |
| t.Errorf(err.Error()) |
| } |
| |
| // Run QEMU. Expect the installer to fail with a predefined error string. |
| expectedOutput := "Couldn't find a suitable block device" |
| result, err := runQemuWithInstaller(ctx, qemuDriveParam(imagePath), expectedOutput) |
| if err != nil { |
| t.Error(err.Error()) |
| } |
| if result != true { |
| t.Errorf("QEMU didn't produce the expected output %q", expectedOutput) |
| } |
| } |
| |
| func TestInstall(t *testing.T) { |
| ctx, ctxC := context.WithCancel(context.Background()) |
| defer ctxC() |
| |
| // Prepare the block device image the installer will install to. |
| storagePath, err := getStorage(4096 + 128 + 128 + 1) |
| defer os.Remove(storagePath) |
| if err != nil { |
| t.Errorf(err.Error()) |
| } |
| |
| // Run QEMU. Expect the installer to succeed. |
| expectedOutput := "Installation completed" |
| result, err := runQemuWithInstaller(ctx, qemuDriveParam(storagePath), expectedOutput) |
| if err != nil { |
| t.Error(err.Error()) |
| } |
| if result != true { |
| t.Errorf("QEMU didn't produce the expected output %q", expectedOutput) |
| } |
| |
| // Verify the resulting node image. Check whether the node GPT was created. |
| storage, err := diskfs.OpenWithMode(storagePath, diskfs.ReadOnly) |
| if err != nil { |
| t.Errorf("Couldn't open the resulting node image at %q: %s", storagePath, err.Error()) |
| } |
| // Verify that GPT exists. |
| ti, err := storage.GetPartitionTable() |
| if ti.Type() != "gpt" { |
| t.Error("Couldn't verify that the resulting node image contains a GPT.") |
| } |
| // Check that the first partition is likely to be a valid ESP. |
| pi := ti.GetPartitions() |
| esp := (pi[0]).(*gpt.Partition) |
| if esp.Name != osimage.ESPVolumeLabel || esp.Start == 0 || esp.End == 0 { |
| t.Error("The node's ESP GPT entry looks off.") |
| } |
| // Verify the system partition's GPT entry. |
| system := (pi[1]).(*gpt.Partition) |
| if system.Name != osimage.SystemVolumeLabel || system.Start == 0 || system.End == 0 { |
| t.Error("The node's system partition GPT entry looks off.") |
| } |
| // Verify the data partition's GPT entry. |
| data := (pi[2]).(*gpt.Partition) |
| if data.Name != osimage.DataVolumeLabel || data.Start == 0 || data.End == 0 { |
| t.Errorf("The node's data partition GPT entry looks off.") |
| } |
| // Verify that there are no more partitions. |
| fourth := (pi[3]).(*gpt.Partition) |
| if fourth.Name != "" || fourth.Start != 0 || fourth.End != 0 { |
| t.Error("The resulting node image contains more partitions than expected.") |
| } |
| // Verify the ESP contents. |
| if err := checkEspContents(storage); err != nil { |
| t.Error(err.Error()) |
| } |
| storage.File.Close() |
| // Run QEMU again. Expect TestOS to launch successfully. |
| expectedOutput = "_TESTOS_LAUNCH_SUCCESS_" |
| result, err = runQemu(ctx, qemuDriveParam(storagePath), expectedOutput) |
| if err != nil { |
| t.Error(err.Error()) |
| } |
| if result != true { |
| t.Errorf("QEMU didn't produce the expected output %q", expectedOutput) |
| } |
| } |