diff --git a/builder/qemu/builder.go b/builder/qemu/builder.go index 4a86b5f4f..c63069fd1 100644 --- a/builder/qemu/builder.go +++ b/builder/qemu/builder.go @@ -185,6 +185,16 @@ type Config struct { // `vmxnet3`, `i82558a` or `i82558b`. The Qemu builder uses `virtio-net` by // default. NetDevice string `mapstructure:"net_device" required:"false"` + // Connects the network to this bridge instead of using the user mode + // networking. + // + // **NB** This bridge must already exist. You can use the `virbr0` bridge + // as created by vagrant-libvirt. + // + // **NB** This will automatically enable the QMP socket (see QMPEnable). + // + // **NB** This only works in Linux based OSes. + NetBridge string `mapstructure:"net_bridge" required:"false"` // This is the path to the directory where the // resulting virtual machine will be created. This may be relative or absolute. // If relative, the path is relative to the working directory when packer @@ -534,7 +544,16 @@ func (b *Builder) Prepare(raws ...interface{}) ([]string, []string, error) { errs, fmt.Errorf("vnc_port_min must be less than vnc_port_max")) } - if b.config.VNCUsePassword && b.config.QMPSocketPath == "" { + if b.config.NetBridge != "" && runtime.GOOS != "linux" { + errs = packer.MultiErrorAppend( + errs, fmt.Errorf("net_bridge is only supported in Linux based OSes")) + } + + if b.config.NetBridge != "" || b.config.VNCUsePassword { + b.config.QMPEnable = true + } + + if b.config.QMPEnable && b.config.QMPSocketPath == "" { socketName := fmt.Sprintf("%s.monitor", b.config.VMName) b.config.QMPSocketPath = filepath.Join(b.config.OutputDir, socketName) } @@ -603,7 +622,7 @@ func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (pack }, ) - if b.config.Comm.Type != "none" { + if b.config.Comm.Type != "none" && b.config.NetBridge == "" { steps = append(steps, new(stepForwardSSH), ) @@ -613,12 +632,17 @@ func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (pack new(stepConfigureVNC), steprun, &stepConfigureQMP{ - VNCUsePassword: b.config.VNCUsePassword, - QMPSocketPath: b.config.QMPSocketPath, + QMPSocketPath: b.config.QMPSocketPath, }, &stepTypeBootCommand{}, ) + if b.config.Comm.Type != "none" && b.config.NetBridge != "" { + steps = append(steps, + new(stepWaitGuestAddress), + ) + } + if b.config.Comm.Type != "none" { steps = append(steps, &communicator.StepConnect{ diff --git a/builder/qemu/builder.hcl2spec.go b/builder/qemu/builder.hcl2spec.go index 6f46ec201..4b5689d66 100644 --- a/builder/qemu/builder.hcl2spec.go +++ b/builder/qemu/builder.hcl2spec.go @@ -96,6 +96,7 @@ type FlatConfig struct { MachineType *string `mapstructure:"machine_type" required:"false" cty:"machine_type"` MemorySize *int `mapstructure:"memory" required:"false" cty:"memory"` NetDevice *string `mapstructure:"net_device" required:"false" cty:"net_device"` + NetBridge *string `mapstructure:"net_bridge" required:"false" cty:"net_bridge"` OutputDir *string `mapstructure:"output_directory" required:"false" cty:"output_directory"` QemuArgs [][]string `mapstructure:"qemuargs" required:"false" cty:"qemuargs"` QemuBinary *string `mapstructure:"qemu_binary" required:"false" cty:"qemu_binary"` @@ -212,6 +213,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "machine_type": &hcldec.AttrSpec{Name: "machine_type", Type: cty.String, Required: false}, "memory": &hcldec.AttrSpec{Name: "memory", Type: cty.Number, Required: false}, "net_device": &hcldec.AttrSpec{Name: "net_device", Type: cty.String, Required: false}, + "net_bridge": &hcldec.AttrSpec{Name: "net_bridge", Type: cty.String, Required: false}, "output_directory": &hcldec.AttrSpec{Name: "output_directory", Type: cty.String, Required: false}, "qemuargs": &hcldec.AttrSpec{Name: "qemuargs", Type: cty.List(cty.List(cty.String)), Required: false}, "qemu_binary": &hcldec.AttrSpec{Name: "qemu_binary", Type: cty.String, Required: false}, diff --git a/builder/qemu/qmp.go b/builder/qemu/qmp.go new file mode 100644 index 000000000..04c4bea14 --- /dev/null +++ b/builder/qemu/qmp.go @@ -0,0 +1,135 @@ +package qemu + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/digitalocean/go-qemu/qmp" +) + +type qomListRequest struct { + Execute string `json:"execute"` + Arguments qomListRequestArguments `json:"arguments"` +} + +type qomListRequestArguments struct { + Path string `json:"path"` +} + +type qomListResponse struct { + Return []qomListReturn `json:"return"` +} + +type qomListReturn struct { + Name string `json:"name"` + Type string `json:"type"` +} + +func qmpQomList(qmpMonitor *qmp.SocketMonitor, path string) ([]qomListReturn, error) { + request, _ := json.Marshal(qomListRequest{ + Execute: "qom-list", + Arguments: qomListRequestArguments{ + Path: path, + }, + }) + result, err := qmpMonitor.Run(request) + if err != nil { + return nil, err + } + var response qomListResponse + if err := json.Unmarshal(result, &response); err != nil { + return nil, err + } + return response.Return, nil +} + +type qomGetRequest struct { + Execute string `json:"execute"` + Arguments qomGetRequestArguments `json:"arguments"` +} + +type qomGetRequestArguments struct { + Path string `json:"path"` + Property string `json:"property"` +} + +type qomGetResponse struct { + Return string `json:"return"` +} + +func qmpQomGet(qmpMonitor *qmp.SocketMonitor, path string, property string) (string, error) { + request, _ := json.Marshal(qomGetRequest{ + Execute: "qom-get", + Arguments: qomGetRequestArguments{ + Path: path, + Property: property, + }, + }) + result, err := qmpMonitor.Run(request) + if err != nil { + return "", err + } + var response qomGetResponse + if err := json.Unmarshal(result, &response); err != nil { + return "", err + } + return response.Return, nil +} + +type netDevice struct { + Path string + Name string + Type string + MacAddress string +} + +func getNetDevices(qmpMonitor *qmp.SocketMonitor) ([]netDevice, error) { + devices := []netDevice{} + for _, parentPath := range []string{"/machine/peripheral", "/machine/peripheral-anon"} { + listResponse, err := qmpQomList(qmpMonitor, parentPath) + if err != nil { + return nil, fmt.Errorf("failed to get qmp qom list %v: %w", parentPath, err) + } + for _, p := range listResponse { + if strings.HasPrefix(p.Type, "child<") { + path := fmt.Sprintf("%s/%s", parentPath, p.Name) + r, err := qmpQomList(qmpMonitor, path) + if err != nil { + return nil, fmt.Errorf("failed to get qmp qom list %v: %w", path, err) + } + isNetdev := false + for _, d := range r { + if d.Name == "netdev" { + isNetdev = true + break + } + } + if isNetdev { + device := netDevice{ + Path: path, + } + for _, d := range r { + if d.Name != "type" && d.Name != "netdev" && d.Name != "mac" { + continue + } + value, err := qmpQomGet(qmpMonitor, path, d.Name) + if err != nil { + return nil, fmt.Errorf("failed to get qmp qom property %v %v: %w", path, d.Name, err) + } + switch d.Name { + case "type": + device.Type = value + case "netdev": + device.Name = value + case "mac": + device.MacAddress = value + } + } + devices = append(devices, device) + } + } + } + } + return devices, nil +} diff --git a/builder/qemu/ssh.go b/builder/qemu/ssh.go index 828c3946d..8f4ceee5c 100644 --- a/builder/qemu/ssh.go +++ b/builder/qemu/ssh.go @@ -13,11 +13,18 @@ func commHost(host string) func(multistep.StateBag) (string, error) { return host, nil } + if guestAddress, ok := state.Get("guestAddress").(string); ok { + return guestAddress, nil + } + return "127.0.0.1", nil } } func commPort(state multistep.StateBag) (int, error) { - sshHostPort := state.Get("sshHostPort").(int) + sshHostPort, ok := state.Get("sshHostPort").(int) + if !ok { + sshHostPort = 22 + } return int(sshHostPort), nil } diff --git a/builder/qemu/step_configure_qmp.go b/builder/qemu/step_configure_qmp.go index a9f177893..8a2d10249 100644 --- a/builder/qemu/step_configure_qmp.go +++ b/builder/qemu/step_configure_qmp.go @@ -20,15 +20,15 @@ import ( // // Produces: type stepConfigureQMP struct { - monitor *qmp.SocketMonitor - VNCUsePassword bool - QMPSocketPath string + monitor *qmp.SocketMonitor + QMPSocketPath string } func (s *stepConfigureQMP) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + config := state.Get("config").(*Config) ui := state.Get("ui").(packer.Ui) - if !s.VNCUsePassword { + if !config.QMPEnable { return multistep.ActionContinue } @@ -46,12 +46,10 @@ func (s *stepConfigureQMP) Run(ctx context.Context, state multistep.StateBag) mu ui.Error(err.Error()) return multistep.ActionHalt } - QMPMonitor := s.monitor - vncPassword := state.Get("vnc_password") // Connect to QMP // function automatically calls capabilities so is immediately ready for commands - err = QMPMonitor.Connect() + err = s.monitor.Connect() if err != nil { err := fmt.Errorf("Error connecting to QMP socket: %s", err) state.Put("error", err) @@ -60,21 +58,22 @@ func (s *stepConfigureQMP) Run(ctx context.Context, state multistep.StateBag) mu } log.Printf("QMP socket open SUCCESS") - cmd = []byte(fmt.Sprintf("{ \"execute\": \"change-vnc-password\", \"arguments\": { \"password\": \"%s\" } }", - vncPassword)) - result, err = QMPMonitor.Run(cmd) - if err != nil { - err := fmt.Errorf("Error connecting to QMP socket: %s", err) - state.Put("error", err) - ui.Error(err.Error()) - return multistep.ActionHalt + vncPassword := state.Get("vnc_password") + if vncPassword != "" { + cmd = []byte(fmt.Sprintf("{ \"execute\": \"change-vnc-password\", \"arguments\": { \"password\": \"%s\" } }", + vncPassword)) + result, err = s.monitor.Run(cmd) + if err != nil { + err := fmt.Errorf("Error connecting to QMP socket: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + log.Printf("QMP Command: %s\nResult: %s", cmd, result) } - log.Printf("QMP Command: %s\nResult: %s", cmd, result) - - // Put QMP monitor in statebag in case there is a use in a following step - // Uncomment for future case as it is unused for now - //state.Put("qmp_monitor", QMPMonitor) + // make the qmp_monitor available to other steps. + state.Put("qmp_monitor", s.monitor) return multistep.ActionContinue } diff --git a/builder/qemu/step_http_ip_discover.go b/builder/qemu/step_http_ip_discover.go index 856371e4e..dd0f88dea 100644 --- a/builder/qemu/step_http_ip_discover.go +++ b/builder/qemu/step_http_ip_discover.go @@ -2,8 +2,11 @@ package qemu import ( "context" + "fmt" + "net" "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" ) // Step to discover the http ip @@ -12,7 +15,52 @@ import ( type stepHTTPIPDiscover struct{} func (s *stepHTTPIPDiscover) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { - state.Put("http_ip", "10.0.2.2") + config := state.Get("config").(*Config) + ui := state.Get("ui").(packer.Ui) + + hostIP := "" + + if config.NetBridge == "" { + hostIP = "10.0.2.2" + } else { + bridgeInterface, err := net.InterfaceByName(config.NetBridge) + if err != nil { + err := fmt.Errorf("Error getting the bridge %s interface: %s", config.NetBridge, err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + addrs, err := bridgeInterface.Addrs() + if err != nil { + err := fmt.Errorf("Error getting the bridge %s interface addresses: %s", config.NetBridge, err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil { + continue + } + ip = ip.To4() + if ip == nil { + continue + } + hostIP = ip.String() + break + } + if hostIP == "" { + err := fmt.Errorf("Error getting an IPv4 address from the bridge %s: cannot find any IPv4 address", config.NetBridge) + ui.Error(err.Error()) + return multistep.ActionHalt + } + } + + state.Put("http_ip", hostIP) return multistep.ActionContinue } diff --git a/builder/qemu/step_http_ip_discover_test.go b/builder/qemu/step_http_ip_discover_test.go index 952e7e296..456602f8a 100644 --- a/builder/qemu/step_http_ip_discover_test.go +++ b/builder/qemu/step_http_ip_discover_test.go @@ -1,14 +1,22 @@ package qemu import ( + "bytes" "context" "testing" "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" ) func TestStepHTTPIPDiscover_Run(t *testing.T) { state := new(multistep.BasicStateBag) + state.Put("ui", &packer.BasicUi{ + Reader: new(bytes.Buffer), + Writer: new(bytes.Buffer), + }) + config := &Config{} + state.Put("config", config) step := new(stepHTTPIPDiscover) hostIp := "10.0.2.2" diff --git a/builder/qemu/step_run.go b/builder/qemu/step_run.go index e8c327f4a..900162f4f 100644 --- a/builder/qemu/step_run.go +++ b/builder/qemu/step_run.go @@ -79,16 +79,24 @@ func getCommandArgs(bootDrive string, state multistep.StateBag) ([]string, error vnc = fmt.Sprintf("%s:%d", vncIP, vncPort-5900) } else { vnc = fmt.Sprintf("%s:%d,password", vncIP, vncPort-5900) + } + + if config.QMPEnable { defaultArgs["-qmp"] = fmt.Sprintf("unix:%s,server,nowait", config.QMPSocketPath) } defaultArgs["-name"] = vmName defaultArgs["-machine"] = fmt.Sprintf("type=%s", config.MachineType) - if config.Comm.Type != "none" { - sshHostPort = state.Get("sshHostPort").(int) - defaultArgs["-netdev"] = fmt.Sprintf("user,id=user.0,hostfwd=tcp::%v-:%d", sshHostPort, config.Comm.Port()) + + if config.NetBridge == "" { + if config.Comm.Type != "none" { + sshHostPort = state.Get("sshHostPort").(int) + defaultArgs["-netdev"] = fmt.Sprintf("user,id=user.0,hostfwd=tcp::%v-:%d", sshHostPort, config.Comm.Port()) + } else { + defaultArgs["-netdev"] = fmt.Sprintf("user,id=user.0") + } } else { - defaultArgs["-netdev"] = fmt.Sprintf("user,id=user.0") + defaultArgs["-netdev"] = fmt.Sprintf("bridge,id=user.0,br=%s", config.NetBridge) } rawVersion, err := driver.Version() @@ -215,11 +223,12 @@ func getCommandArgs(bootDrive string, state multistep.StateBag) ([]string, error if len(config.QemuArgs) > 0 { ui.Say("Overriding defaults Qemu arguments with QemuArgs...") + httpIp := state.Get("http_ip").(string) httpPort := state.Get("http_port").(int) ictx := config.ctx if config.Comm.Type != "none" { ictx.Data = qemuArgsTemplateData{ - "10.0.2.2", + httpIp, httpPort, config.HTTPDir, config.OutputDir, @@ -228,7 +237,7 @@ func getCommandArgs(bootDrive string, state multistep.StateBag) ([]string, error } } else { ictx.Data = qemuArgsTemplateData{ - HTTPIP: "10.0.2.2", + HTTPIP: httpIp, HTTPPort: httpPort, HTTPDir: config.HTTPDir, OutputDir: config.OutputDir, diff --git a/builder/qemu/step_wait_guest_address.go b/builder/qemu/step_wait_guest_address.go new file mode 100644 index 000000000..eff4e975e --- /dev/null +++ b/builder/qemu/step_wait_guest_address.go @@ -0,0 +1,122 @@ +package qemu + +import ( + "bufio" + "context" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + + "github.com/digitalocean/go-qemu/qmp" +) + +// This step waits for the guest address to become available in the network +// bridge, then it sets the guestAddress state property. +type stepWaitGuestAddress struct{} + +func (s *stepWaitGuestAddress) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + config := state.Get("config").(*Config) + ui := state.Get("ui").(packer.Ui) + qmpMonitor := state.Get("qmp_monitor").(*qmp.SocketMonitor) + + ui.Say(fmt.Sprintf("Waiting for the guest address to become available in the %s network bridge...", config.NetBridge)) + for { + guestAddress := getGuestAddress(qmpMonitor, config.NetBridge, "user.0") + if guestAddress != "" { + log.Printf("Found guest address %s", guestAddress) + state.Put("guestAddress", guestAddress) + break + } + select { + case <-time.After(10 * time.Second): + continue + case <-ctx.Done(): + return multistep.ActionHalt + } + } + + return multistep.ActionContinue +} + +func (s *stepWaitGuestAddress) Cleanup(state multistep.StateBag) { +} + +func getGuestAddress(qmpMonitor *qmp.SocketMonitor, bridgeName string, deviceName string) string { + devices, err := getNetDevices(qmpMonitor) + if err != nil { + log.Printf("Could not retrieve QEMU QMP network device list: %v", err) + return "" + } + + for _, device := range devices { + if device.Name == deviceName { + ipAddress, _ := getDeviceIPAddress(bridgeName, device.MacAddress) + return ipAddress + } + } + + log.Printf("QEMU QMP network device %s was not found", deviceName) + return "" +} + +func getDeviceIPAddress(device string, macAddress string) (string, error) { + // this parses /proc/net/arp to retrieve the given device IP address. + // + // /proc/net/arp is normally someting alike: + // + // IP address HW type Flags HW address Mask Device + // 192.168.121.111 0x1 0x2 52:54:00:12:34:56 * virbr0 + // + + const ( + IPAddressIndex int = iota + HWTypeIndex + FlagsIndex + HWAddressIndex + MaskIndex + DeviceIndex + ) + + // see ARP flags at https://github.com/torvalds/linux/blob/v5.4/include/uapi/linux/if_arp.h#L132 + const ( + AtfCom int = 0x02 // ATF_COM (complete) + ) + + f, err := os.Open("/proc/net/arp") + if err != nil { + return "", fmt.Errorf("failed to open /proc/net/arp: %w", err) + } + defer f.Close() + + s := bufio.NewScanner(f) + s.Scan() + + for s.Scan() { + fields := strings.Fields(s.Text()) + + if device != "" && fields[DeviceIndex] != device { + continue + } + + if fields[HWAddressIndex] != macAddress { + continue + } + + flags, err := strconv.ParseInt(fields[FlagsIndex], 0, 32) + if err != nil { + return "", fmt.Errorf("failed to parse /proc/net/arp flags field %s: %w", fields[FlagsIndex], err) + } + + if int(flags)&AtfCom == AtfCom { + return fields[IPAddressIndex], nil + } + } + + return "", fmt.Errorf("could not find %s", macAddress) +} diff --git a/website/pages/partials/builder/qemu/Config-not-required.mdx b/website/pages/partials/builder/qemu/Config-not-required.mdx index d88a2be42..edc950048 100644 --- a/website/pages/partials/builder/qemu/Config-not-required.mdx +++ b/website/pages/partials/builder/qemu/Config-not-required.mdx @@ -108,6 +108,16 @@ `vmxnet3`, `i82558a` or `i82558b`. The Qemu builder uses `virtio-net` by default. +- `net_bridge` (string) - Connects the network to this bridge instead of using the user mode + networking. + + **NB** This bridge must already exist. You can use the `virbr0` bridge + as created by vagrant-libvirt. + + **NB** This will automatically enable the QMP socket (see QMPEnable). + + **NB** This only works in Linux based OSes. + - `output_directory` (string) - This is the path to the directory where the resulting virtual machine will be created. This may be relative or absolute. If relative, the path is relative to the working directory when packer