From 26b0a06bdca01347d48ba727efe69a642ede4e8c Mon Sep 17 00:00:00 2001 From: Jo Date: Wed, 28 Sep 2022 10:02:32 +0200 Subject: [PATCH] plugins/teststeps/qemu.go: Add qemu teststep. Makes it possible to test and interact with firmware/ OS images in qemu. For more information on how to use the plugin: plugins/teststeps/qemu/README.md Signed-off-by: Jo --- go.mod | 3 + go.sum | 2 + plugins/teststeps/qemu/README.md | 56 +++++++ plugins/teststeps/qemu/qemu.go | 277 +++++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 plugins/teststeps/qemu/README.md create mode 100644 plugins/teststeps/qemu/qemu.go diff --git a/go.mod b/go.mod index ce4d4c58..3eb4dfdc 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gin-gonic/gin v1.8.1 github.com/go-sql-driver/mysql v1.6.0 github.com/google/go-safeweb v0.0.0-20211026121254-697f59a9d57f + github.com/google/goexpect v0.0.0-20200703111054-623d5ca06f56 github.com/google/uuid v1.3.0 github.com/insomniacslk/termhook v0.0.0-20210329134026-a267c978e590 github.com/insomniacslk/xjson v0.0.0-20210106140854-1589ccfd1a1a @@ -49,6 +50,7 @@ require ( github.com/goccy/go-json v0.9.8 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.1 // indirect + github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f // indirect github.com/hugelgupf/p9 v0.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -86,5 +88,6 @@ require ( golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.1.12 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect + google.golang.org/grpc v1.31.0 // indirect google.golang.org/protobuf v1.28.0 // indirect ) diff --git a/go.sum b/go.sum index 3580f935..7559106f 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,7 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/goccy/go-json v0.9.8 h1:DxXB6MLd6yyel7CLph8EwNIonUtVZd3Ue5iRcL4DQCE= github.com/goccy/go-json v0.9.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -648,6 +649,7 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= diff --git a/plugins/teststeps/qemu/README.md b/plugins/teststeps/qemu/README.md new file mode 100644 index 00000000..b8618f70 --- /dev/null +++ b/plugins/teststeps/qemu/README.md @@ -0,0 +1,56 @@ +# Qemu Teststep + + +## Parameters + +### Required Parameters +* **executable:** Name of the qemu executable. It can be an absolute path or the name of a executable in $PATH. + +* **firmware:** The firmware image you want to test. + + +### Optional Paramters +* **logfile:** The output of the running image is copied here. If left empty the output will be discarded by setting the logfile to /dev/null. + +* **mem:** The amount of RAM dedicated to the qemu instance in MB. + +* **nproc:** The amount of threads available to qemu. + +* **image:** A Disk Image, which can be booted by the firmware. + +* **timeout:** The time intervall until the qemu instance is forcibly shut down. Example: '4m' + +* **steps:** This is a list of steps, which can consist of expect or send steps. An expect steps expects a certain output from the virtual machine. A send step will send a string to qemu. Expect steps can have an additional timeout field, which is a string, like '2m'. If left empty the **timeout** Parameter is used as timeout instead. Make sure the timout you set for an expect step is shorter than the overall timeout. A step can have both an expect as well as a send statement; this is interpreted as an expect step, which is followed by a send step if it is successful. The steps are executed in order beginning from the first entry. Each step is blocking, meaning the next step will be executed only if the previous step was successful. +Example: + steps: + - expect: 'Welcome .*please login:' + timeout: 3s + send: username + - expect: Password + - send: secretPassword + - expect: Login successful +Notice that regular expressions have to be surrounded by single quotes. + + - name: qemu + label: awesome test + parameters: + executable: ['qemu-system-aarch64] + firmware: ['/my/awesome/firmware'] + image: ['/home/user1/images/Linux.qcow2'] + nproc: [8] + mem: [8000] + logfile: [/tmp/Logfile] + timeout: [4m] + steps: + - expect: Booting into OS + timeout: 4s + - expect: '\nKernel' + - expect: login + - send: user + - expect: Password + - timeout: 5s + - send: 12345password + - expect: user@ + - timeout: 10s + - send: poweroff + - expect: Power down diff --git a/plugins/teststeps/qemu/qemu.go b/plugins/teststeps/qemu/qemu.go new file mode 100644 index 00000000..65dfa776 --- /dev/null +++ b/plugins/teststeps/qemu/qemu.go @@ -0,0 +1,277 @@ +package qemu + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "time" + + expect "github.com/google/goexpect" + "github.com/linuxboot/contest/pkg/event" + "github.com/linuxboot/contest/pkg/event/testevent" + "github.com/linuxboot/contest/pkg/target" + "github.com/linuxboot/contest/pkg/test" + "github.com/linuxboot/contest/pkg/xcontext" + "github.com/linuxboot/contest/plugins/teststeps" +) + +const ( + defaultTimeout = "10m" + defaultNproc = "3" + defaultMemory = "5000" +) + +// Name of the plugin +var Name = "Qemu" + +var Events = []event.Name{} + +type Qemu struct { + executable *test.Param + firmware *test.Param + nproc *test.Param + mem *test.Param + image *test.Param + logfile *test.Param + timeout *test.Param + steps []test.Param +} + +// Needed for the Teststep interface. Returns a Teststep instance. +func New() test.TestStep { + return &Qemu{} +} + +// Needed for the Teststep interface. Returns the Name, the New() Function and +// the events the teststep can emit (which are no events). +func Load() (string, test.TestStepFactory, []event.Name) { + return Name, New, Events +} + +// Name returns the name of the Step +func (q Qemu) Name() string { + return Name +} + +// ValidateParameters validates the parameters that will be passed to the Run +// and Resume methods of the test step. +func (q *Qemu) ValidateParameters(_ xcontext.Context, params test.TestStepParameters) error { + if q.executable = params.GetOne("executable"); q.executable.IsEmpty() { + return fmt.Errorf("No Qemu executable given") + } + + if q.firmware = params.GetOne("firmware"); q.firmware.IsEmpty() { + return errors.New("Missing 'firmware' field in qemu parameters") + } + + if q.nproc = params.GetOne("nproc"); q.nproc.IsEmpty() { + q.nproc = test.NewParam(defaultNproc) + } + if q.mem = params.GetOne("mem"); q.mem.IsEmpty() { + q.mem = test.NewParam(defaultMemory) + } + if q.timeout = params.GetOne("timeout"); q.timeout.IsEmpty() { + q.timeout = test.NewParam(defaultTimeout) + } + + q.image = params.GetOne("image") + + q.steps = params.Get("steps") + + q.logfile = params.GetOne("logfile") + + return nil +} + +func (q *Qemu) validateAndPopulate(ctx xcontext.Context, params test.TestStepParameters) error { + return q.ValidateParameters(ctx, params) +} + +// Run starts the Qemu instance for each target and interacts with the qemu instance +// through the expect and send steps. +func (q *Qemu) Run(ctx xcontext.Context, + ch test.TestStepChannels, + ev testevent.Emitter, + stepsVars test.StepsVariables, + params test.TestStepParameters, + resumeState json.RawMessage, +) (json.RawMessage, error) { + log := ctx.Logger() + + if err := q.validateAndPopulate(ctx, params); err != nil { + return nil, err + } + f := func(ctx xcontext.Context, target *target.Target) error { + targetTimeout, err := q.timeout.Expand(target, stepsVars) + if err != nil { + return err + } + + targetLogfile, err := q.logfile.Expand(target, stepsVars) + if err != nil { + return err + } + + targetImage, err := q.image.Expand(target, stepsVars) + if err != nil { + return err + } + + targetQemu, err := q.executable.Expand(target, stepsVars) + if err != nil { + return err + } + + // basic checks whether the executable is usable + if abs := filepath.IsAbs(targetQemu); !abs { + _, err := exec.LookPath(targetQemu) + if err != nil { + return fmt.Errorf("unable to find qemu executable in PATH: %w", err) + } + } + + targetFirmware, err := q.firmware.Expand(target, stepsVars) + if err != nil { + return err + } + + targetMem, err := q.mem.Expand(target, stepsVars) + if err != nil { + return err + } + + targetNproc, err := q.nproc.Expand(target, stepsVars) + if err != nil { + return err + } + + globalTimeout, err := time.ParseDuration(targetTimeout) + if err != nil { + return fmt.Errorf("Could not Parse %v as Timeout: %w", targetTimeout, err) + } + + // no graphical output and no network access + command := []string{targetQemu, "-nographic", "-nic", "none", "-bios", targetFirmware} + qemuOpts := []string{"-m", targetMem, "-smp", targetNproc} + + command = append(command, qemuOpts...) + if targetImage != "" { + command = append(command, targetImage) + } + + var logfile *os.File + if targetLogfile != "" { + + logfile, err = os.Create(targetLogfile) + if err != nil { + return fmt.Errorf("Could not create Logfile: %w", err) + } + defer logfile.Close() + } else { + logfile, err = os.OpenFile("/dev/null", os.O_WRONLY, fs.ModeDevice) + if err != nil { + return fmt.Errorf("Could not redirect output to '/dev/null': %w", err) + } + defer logfile.Close() + } + + gExpect, errchan, err := expect.SpawnWithArgs( + command, + globalTimeout, + expect.Tee(logfile), + expect.CheckDuration(time.Minute), + expect.PartialMatch(false), + expect.SendTimeout(globalTimeout), + ) + if err != nil { + return fmt.Errorf("Could not start qemu: %w", err) + } + + log.Infof("Started Qemu with command: %v", command) + + defer gExpect.Close() + + defer func() { + select { + case err = <-errchan: + log.Errorf("Error from Qemu: %v", err) + default: + + } + }() + + // struct to capture expect and send strings from json. + type expector struct { + Expect string + Send string + Timeout string + } + + // loop over all steps and expect/ send the given strings + for _, interaction := range q.steps { + + dst := new(expector) + + jsString, err := interaction.Expand(target, stepsVars) + if err != nil { + return err + } + + interactionParam := test.NewParam(jsString) + js := interactionParam.JSON() + + if err := json.Unmarshal(js, dst); err != nil { + return fmt.Errorf("Could not Unmarshal steps: %w", err) + } + + // Expect and Send fields must not both be empty + if dst.Expect+dst.Send == "" { + return fmt.Errorf("%s is not a valid step statement", interaction.String()) + } + + // process expect step + if dst.Expect != "" { + + var timeout time.Duration + if dst.Timeout == "" { + timeout = globalTimeout + } else { + if timeout, err = time.ParseDuration(dst.Timeout); err != nil { + return fmt.Errorf("Could not parse timeout '%s' for step: '%s'. %w", dst.Timeout, dst.Expect, err) + } + } + + if _, _, err := gExpect.Expect(regexp.MustCompile(dst.Expect), timeout); err != nil { + return fmt.Errorf("Error while expecting '%s': %w", dst.Expect, err) + } + + log.Debugf("Completed expect step: '%v' with timeout: %v \n", dst.Expect, timeout.String()) + + } + + // process send step + if dst.Send != "" { + + if err := gExpect.Send(dst.Send + "\n"); err != nil { + return fmt.Errorf("Unable to send '%s': %w", dst.Send, err) + } + + // notify the user if the timeout field is used incorrectly + if dst.Expect == "" && dst.Timeout != "" { + log.Warnf("The Timeout %v for send step: %v will be ignored.", dst.Timeout, dst.Send) + } + + log.Debugf("Completed Send Step: '%v'", dst.Send) + } + } + + log.Infof("Matching steps successful") + return nil + } + return teststeps.ForEachTarget(Name, ctx, ch, f) +}