diff options
author | OpenShift Merge Robot <openshift-merge-robot@users.noreply.github.com> | 2021-12-26 13:26:07 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-12-26 13:26:07 +0100 |
commit | e06631d6c22f4d5b7a62f70ccdf623379a9d5fe7 (patch) | |
tree | 54fe3a08b58b9129f87e51cd1b8fcd938f582777 /pkg/machine | |
parent | 73a54ea54d0a1b4ccaa2a0e23c678e5b7c1d5c37 (diff) | |
parent | 803defbe509af1902a1fdc2ed7f41b49ebd241f6 (diff) | |
download | podman-e06631d6c22f4d5b7a62f70ccdf623379a9d5fe7.tar.gz podman-e06631d6c22f4d5b7a62f70ccdf623379a9d5fe7.tar.bz2 podman-e06631d6c22f4d5b7a62f70ccdf623379a9d5fe7.zip |
Merge pull request #12503 from n1hility/wsl-machine
Introduce Windows WSL implementation of podman machine
Diffstat (limited to 'pkg/machine')
-rw-r--r-- | pkg/machine/config.go | 16 | ||||
-rw-r--r-- | pkg/machine/connection.go | 3 | ||||
-rw-r--r-- | pkg/machine/fcos.go | 29 | ||||
-rw-r--r-- | pkg/machine/fedora.go | 122 | ||||
-rw-r--r-- | pkg/machine/ignition.go | 2 | ||||
-rw-r--r-- | pkg/machine/ignition_schema.go | 2 | ||||
-rw-r--r-- | pkg/machine/ignition_windows.go | 7 | ||||
-rw-r--r-- | pkg/machine/keys.go | 47 | ||||
-rw-r--r-- | pkg/machine/machine_unsupported.go | 2 | ||||
-rw-r--r-- | pkg/machine/pull.go | 96 | ||||
-rw-r--r-- | pkg/machine/qemu/config.go | 4 | ||||
-rw-r--r-- | pkg/machine/qemu/machine.go | 72 | ||||
-rw-r--r-- | pkg/machine/qemu/machine_unsupported.go | 2 | ||||
-rw-r--r-- | pkg/machine/wsl/machine.go | 1119 | ||||
-rw-r--r-- | pkg/machine/wsl/machine_unsupported.go | 3 | ||||
-rw-r--r-- | pkg/machine/wsl/util_windows.go | 338 |
16 files changed, 1764 insertions, 100 deletions
diff --git a/pkg/machine/config.go b/pkg/machine/config.go index e5e701303..4f2947ac0 100644 --- a/pkg/machine/config.go +++ b/pkg/machine/config.go @@ -1,4 +1,4 @@ -// +build amd64,!windows arm64,!windows +// +build amd64 arm64 package machine @@ -24,6 +24,15 @@ type InitOptions struct { TimeZone string URI url.URL Username string + ReExec bool +} + +type Provider interface { + NewMachine(opts InitOptions) (VM, error) + LoadVMByName(name string) (VM, error) + List(opts ListOptions) ([]*ListResponse, error) + IsValidVMName(name string) (bool, error) + CheckExclusiveActiveVM() (bool, string, error) } type RemoteConnectionType string @@ -49,6 +58,7 @@ type Download struct { Sha256sum string URL *url.URL VMName string + Size int64 } type ListOptions struct{} @@ -81,7 +91,7 @@ type RemoveOptions struct { } type VM interface { - Init(opts InitOptions) error + Init(opts InitOptions) (bool, error) Remove(name string, opts RemoveOptions) (string, func() error, error) SSH(name string, opts SSHOptions) error Start(name string, opts StartOptions) error @@ -89,7 +99,7 @@ type VM interface { } type DistributionDownload interface { - DownloadImage() error + HasUsableCache() (bool, error) Get() *Download } diff --git a/pkg/machine/connection.go b/pkg/machine/connection.go index ed1093264..d28ffcef1 100644 --- a/pkg/machine/connection.go +++ b/pkg/machine/connection.go @@ -1,4 +1,5 @@ -// +build amd64,!windows arm64,!windows +//go:build amd64 || arm64 +// +build amd64 arm64 package machine diff --git a/pkg/machine/fcos.go b/pkg/machine/fcos.go index 99197ac0e..60ab471ee 100644 --- a/pkg/machine/fcos.go +++ b/pkg/machine/fcos.go @@ -1,4 +1,4 @@ -// +build amd64,!windows arm64,!windows +// +build amd64 arm64 package machine @@ -65,25 +65,6 @@ func NewFcosDownloader(vmType, vmName, imageStream string) (DistributionDownload return fcd, nil } -func (f FcosDownload) getLocalUncompressedName() string { - uncompressedFilename := filepath.Join(filepath.Dir(f.LocalPath), f.VMName+"_"+f.ImageName) - return strings.TrimSuffix(uncompressedFilename, ".xz") -} - -func (f FcosDownload) DownloadImage() error { - // check if the latest image is already present - ok, err := UpdateAvailable(&f.Download) - if err != nil { - return err - } - if !ok { - if err := DownloadVMImage(f.URL, f.LocalPath); err != nil { - return err - } - } - return Decompress(f.LocalPath, f.getLocalUncompressedName()) -} - func (f FcosDownload) Get() *Download { return &f.Download } @@ -95,14 +76,14 @@ type fcosDownloadInfo struct { Sha256Sum string } -func UpdateAvailable(d *Download) (bool, error) { +func (f FcosDownload) HasUsableCache() (bool, error) { // check the sha of the local image if it exists // get the sha of the remote image // == dont bother to pull - if _, err := os.Stat(d.LocalPath); os.IsNotExist(err) { + if _, err := os.Stat(f.LocalPath); os.IsNotExist(err) { return false, nil } - fd, err := os.Open(d.LocalPath) + fd, err := os.Open(f.LocalPath) if err != nil { return false, err } @@ -115,7 +96,7 @@ func UpdateAvailable(d *Download) (bool, error) { if err != nil { return false, err } - return sum.Encoded() == d.Sha256sum, nil + return sum.Encoded() == f.Sha256sum, nil } func getFcosArch() string { diff --git a/pkg/machine/fedora.go b/pkg/machine/fedora.go new file mode 100644 index 000000000..cd713dde7 --- /dev/null +++ b/pkg/machine/fedora.go @@ -0,0 +1,122 @@ +// +build amd64 arm64 + +package machine + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const ( + githubURL = "http://github.com/fedora-cloud/docker-brew-fedora/" +) + +type FedoraDownload struct { + Download +} + +func NewFedoraDownloader(vmType, vmName, releaseStream string) (DistributionDownload, error) { + imageName, downloadURL, size, err := getFedoraDownload(releaseStream) + if err != nil { + return nil, err + } + + dataDir, err := GetDataDir(vmType) + if err != nil { + return nil, err + } + + f := FedoraDownload{ + Download: Download{ + Arch: getFcosArch(), + Artifact: artifact, + Format: Format, + ImageName: imageName, + LocalPath: filepath.Join(dataDir, imageName), + URL: downloadURL, + VMName: vmName, + Size: size, + }, + } + f.Download.LocalUncompressedFile = f.getLocalUncompressedName() + return f, nil +} + +func (f FedoraDownload) Get() *Download { + return &f.Download +} + +func (f FedoraDownload) HasUsableCache() (bool, error) { + info, err := os.Stat(f.LocalPath) + if err != nil { + return false, nil + } + return info.Size() == f.Size, nil +} + +func truncRead(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + + defer func() { + if err := resp.Body.Close(); err != nil { + logrus.Error(err) + } + }() + + body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + if err != nil { + return nil, err + } + + _, _ = io.Copy(io.Discard, resp.Body) + + return body, nil +} + +func getFedoraDownload(releaseStream string) (string, *url.URL, int64, error) { + dirURL := githubURL + "tree/" + releaseStream + "/" + getFcosArch() + "/" + body, err := truncRead(dirURL) + if err != nil { + return "", nil, -1, err + } + + rx, err := regexp.Compile(`fedora[^\"]+xz`) + if err != nil { + return "", nil, -1, err + } + file := rx.FindString(string(body)) + if len(file) <= 0 { + return "", nil, -1, fmt.Errorf("could not locate Fedora download at %s", dirURL) + } + + rawURL := githubURL + "raw/" + releaseStream + "/" + getFcosArch() + "/" + newLocation := rawURL + file + downloadURL, err := url.Parse(newLocation) + if err != nil { + return "", nil, -1, errors.Wrapf(err, "invalid URL generated from discovered Fedora file: %s", newLocation) + } + + resp, err := http.Head(newLocation) + if err != nil { + return "", nil, -1, errors.Wrapf(err, "head request failed: %s", newLocation) + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", nil, -1, fmt.Errorf("head request failed [%d] on download: %s", resp.StatusCode, newLocation) + } + + return file, downloadURL, resp.ContentLength, nil +} diff --git a/pkg/machine/ignition.go b/pkg/machine/ignition.go index 9368cc8ed..139318977 100644 --- a/pkg/machine/ignition.go +++ b/pkg/machine/ignition.go @@ -1,4 +1,4 @@ -// +build amd64,!windows arm64,!windows +// +build amd64 arm64 package machine diff --git a/pkg/machine/ignition_schema.go b/pkg/machine/ignition_schema.go index aa4b8e060..8cfb0d04e 100644 --- a/pkg/machine/ignition_schema.go +++ b/pkg/machine/ignition_schema.go @@ -1,4 +1,4 @@ -// +build amd64,!windows arm64,!windows +// +build amd64 arm64 package machine diff --git a/pkg/machine/ignition_windows.go b/pkg/machine/ignition_windows.go new file mode 100644 index 000000000..c0de48bd3 --- /dev/null +++ b/pkg/machine/ignition_windows.go @@ -0,0 +1,7 @@ +//+build windows + +package machine + +func getLocalTimeZone() (string, error) { + return "", nil +} diff --git a/pkg/machine/keys.go b/pkg/machine/keys.go index 319fc2b4e..711b091f0 100644 --- a/pkg/machine/keys.go +++ b/pkg/machine/keys.go @@ -1,13 +1,21 @@ -// +build amd64,!windows arm64,!windows +// +build amd64 arm64 package machine import ( + "errors" + "fmt" "io/ioutil" + "os" "os/exec" + "path/filepath" "strings" + + "github.com/sirupsen/logrus" ) +var sshCommand = []string{"ssh-keygen", "-N", "", "-t", "ed25519", "-f"} + // CreateSSHKeys makes a priv and pub ssh key for interacting // the a VM. func CreateSSHKeys(writeLocation string) (string, error) { @@ -21,7 +29,42 @@ func CreateSSHKeys(writeLocation string) (string, error) { return strings.TrimSuffix(string(b), "\n"), nil } +func CreateSSHKeysPrefix(dir string, file string, passThru bool, skipExisting bool, prefix ...string) (string, error) { + location := filepath.Join(dir, file) + + _, e := os.Stat(location) + if !skipExisting || errors.Is(e, os.ErrNotExist) { + if err := generatekeysPrefix(dir, file, passThru, prefix...); err != nil { + return "", err + } + } else { + fmt.Println("Keys already exist, reusing") + } + b, err := ioutil.ReadFile(filepath.Join(dir, file) + ".pub") + if err != nil { + return "", err + } + return strings.TrimSuffix(string(b), "\n"), nil +} + // generatekeys creates an ed25519 set of keys func generatekeys(writeLocation string) error { - return exec.Command("ssh-keygen", "-N", "", "-t", "ed25519", "-f", writeLocation).Run() + args := append(append([]string{}, sshCommand[1:]...), writeLocation) + return exec.Command(sshCommand[0], args...).Run() +} + +// generatekeys creates an ed25519 set of keys +func generatekeysPrefix(dir string, file string, passThru bool, prefix ...string) error { + args := append([]string{}, prefix[1:]...) + args = append(args, sshCommand...) + args = append(args, file) + cmd := exec.Command(prefix[0], args...) + cmd.Dir = dir + if passThru { + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + logrus.Debugf("Running wsl cmd %v in dir: %s", args, dir) + return cmd.Run() } diff --git a/pkg/machine/machine_unsupported.go b/pkg/machine/machine_unsupported.go index 9309d16bc..da1437984 100644 --- a/pkg/machine/machine_unsupported.go +++ b/pkg/machine/machine_unsupported.go @@ -1,3 +1,3 @@ -// +build !amd64 amd64,windows +// +build !amd64,!arm64 package machine diff --git a/pkg/machine/pull.go b/pkg/machine/pull.go index 3c8422a30..280b47f96 100644 --- a/pkg/machine/pull.go +++ b/pkg/machine/pull.go @@ -1,8 +1,9 @@ -// +build amd64,!windows arm64,!windows +// +build amd64 arm64 package machine import ( + "bufio" "fmt" "io" "io/ioutil" @@ -17,6 +18,7 @@ import ( "github.com/containers/image/v5/pkg/compression" "github.com/containers/storage/pkg/archive" "github.com/sirupsen/logrus" + "github.com/ulikunitz/xz" "github.com/vbauerster/mpb/v6" "github.com/vbauerster/mpb/v6/decor" ) @@ -43,7 +45,7 @@ func NewGenericDownloader(vmType, vmName, pullPath string) (DistributionDownload return nil, err } if len(getURL.Scheme) > 0 { - urlSplit := strings.Split(pullPath, "/") + urlSplit := strings.Split(getURL.Path, "/") imageName = urlSplit[len(urlSplit)-1] dl.LocalUncompressedFile = filepath.Join(dataDir, imageName) dl.URL = getURL @@ -63,39 +65,48 @@ func NewGenericDownloader(vmType, vmName, pullPath string) (DistributionDownload return gd, nil } -func (g GenericDownload) getLocalUncompressedName() string { +func (d Download) getLocalUncompressedName() string { var ( extension string ) switch { - case strings.HasSuffix(g.LocalPath, ".bz2"): + case strings.HasSuffix(d.LocalPath, ".bz2"): extension = ".bz2" - case strings.HasSuffix(g.LocalPath, ".gz"): + case strings.HasSuffix(d.LocalPath, ".gz"): extension = ".gz" - case strings.HasSuffix(g.LocalPath, ".xz"): + case strings.HasSuffix(d.LocalPath, ".xz"): extension = ".xz" } - uncompressedFilename := filepath.Join(filepath.Dir(g.LocalUncompressedFile), g.VMName+"_"+g.ImageName) + uncompressedFilename := filepath.Join(filepath.Dir(d.LocalPath), d.VMName+"_"+d.ImageName) return strings.TrimSuffix(uncompressedFilename, extension) } -func (g GenericDownload) DownloadImage() error { +func (g GenericDownload) Get() *Download { + return &g.Download +} + +func (g GenericDownload) HasUsableCache() (bool, error) { // If we have a URL for this "downloader", we now pull it - if g.URL != nil { - if err := DownloadVMImage(g.URL, g.LocalPath); err != nil { + return g.URL == nil, nil +} + +func DownloadImage(d DistributionDownload) error { + // check if the latest image is already present + ok, err := d.HasUsableCache() + if err != nil { + return err + } + if !ok { + if err := DownloadVMImage(d.Get().URL, d.Get().LocalPath); err != nil { return err } } - return Decompress(g.LocalPath, g.getLocalUncompressedName()) -} - -func (g GenericDownload) Get() *Download { - return &g.Download + return Decompress(d.Get().LocalPath, d.Get().getLocalUncompressedName()) } // DownloadVMImage downloads a VM image from url to given path // with download status -func DownloadVMImage(downloadURL fmt.Stringer, localImagePath string) error { +func DownloadVMImage(downloadURL *url2.URL, localImagePath string) error { out, err := os.Create(localImagePath) if err != nil { return err @@ -120,7 +131,7 @@ func DownloadVMImage(downloadURL fmt.Stringer, localImagePath string) error { return fmt.Errorf("error downloading VM image %s: %s", downloadURL, resp.Status) } size := resp.ContentLength - urlSplit := strings.Split(downloadURL.String(), "/") + urlSplit := strings.Split(downloadURL.Path, "/") prefix := "Downloading VM image: " + urlSplit[len(urlSplit)-1] onComplete := prefix + ": done" @@ -177,24 +188,50 @@ func Decompress(localPath, uncompressedPath string) error { // Will error out if file without .xz already exists // Maybe extracting then renameing is a good idea here.. // depends on xz: not pre-installed on mac, so it becomes a brew dependency -func decompressXZ(src string, output io.Writer) error { - cmd := exec.Command("xzcat", "-k", src) - //cmd := exec.Command("xz", "-d", "-k", "-v", src) - stdOut, err := cmd.StdoutPipe() - if err != nil { - return err +func decompressXZ(src string, output io.WriteCloser) error { + var read io.Reader + var cmd *exec.Cmd + // Prefer xz utils for fastest performance, fallback to go xi2 impl + if _, err := exec.LookPath("xzcat"); err == nil { + cmd = exec.Command("xzcat", "-k", src) + read, err = cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = os.Stderr + } else { + file, err := os.Open(src) + if err != nil { + return err + } + defer file.Close() + // This XZ implementation is reliant on buffering. It is also 3x+ slower than XZ utils. + // Consider replacing with a faster implementation (e.g. xi2) if podman machine is + // updated with a larger image for the distribution base. + buf := bufio.NewReader(file) + read, err = xz.NewReader(buf) + if err != nil { + return err + } } - //cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + + done := make(chan bool) go func() { - if _, err := io.Copy(output, stdOut); err != nil { + if _, err := io.Copy(output, read); err != nil { logrus.Error(err) } + output.Close() + done <- true }() - return cmd.Run() + + if cmd != nil { + return cmd.Run() + } + <-done + return nil } -func decompressEverythingElse(src string, output io.Writer) error { +func decompressEverythingElse(src string, output io.WriteCloser) error { f, err := os.Open(src) if err != nil { return err @@ -207,6 +244,9 @@ func decompressEverythingElse(src string, output io.Writer) error { if err := uncompressStream.Close(); err != nil { logrus.Error(err) } + if err := output.Close(); err != nil { + logrus.Error(err) + } }() _, err = io.Copy(output, uncompressStream) diff --git a/pkg/machine/qemu/config.go b/pkg/machine/qemu/config.go index c04773450..8404079a2 100644 --- a/pkg/machine/qemu/config.go +++ b/pkg/machine/qemu/config.go @@ -4,6 +4,8 @@ package qemu import "time" +type Provider struct{} + type MachineVM struct { // CPUs to be assigned to the VM CPUs uint64 @@ -44,6 +46,4 @@ var ( // defaultQMPTimeout is the timeout duration for the // qmp monitor interactions defaultQMPTimeout time.Duration = 2 * time.Second - // defaultRemoteUser describes the ssh username default - defaultRemoteUser = "core" ) diff --git a/pkg/machine/qemu/machine.go b/pkg/machine/qemu/machine.go index 19cd131e1..a80a11573 100644 --- a/pkg/machine/qemu/machine.go +++ b/pkg/machine/qemu/machine.go @@ -21,18 +21,24 @@ import ( "github.com/containers/podman/v3/utils" "github.com/containers/storage/pkg/homedir" "github.com/digitalocean/go-qemu/qmp" + "github.com/docker/go-units" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) var ( + qemuProvider = &Provider{} // vmtype refers to qemu (vs libvirt, krun, etc) vmtype = "qemu" ) +func GetQemuProvider() machine.Provider { + return qemuProvider +} + // NewMachine initializes an instance of a virtual machine based on the qemu // virtualization. -func NewMachine(opts machine.InitOptions) (machine.VM, error) { +func (p *Provider) NewMachine(opts machine.InitOptions) (machine.VM, error) { vmConfigDir, err := machine.GetConfDir(vmtype) if err != nil { return nil, err @@ -44,16 +50,8 @@ func NewMachine(opts machine.InitOptions) (machine.VM, error) { ignitionFile := filepath.Join(vmConfigDir, vm.Name+".ign") vm.IgnitionFilePath = ignitionFile - // An image was specified - if len(opts.ImagePath) > 0 { - vm.ImagePath = opts.ImagePath - } - - // Assign remote user name. if not provided, use default + vm.ImagePath = opts.ImagePath vm.RemoteUsername = opts.Username - if len(vm.RemoteUsername) < 1 { - vm.RemoteUsername = defaultRemoteUser - } // Add a random port for ssh port, err := utils.GetRandomPort() @@ -106,7 +104,7 @@ func NewMachine(opts machine.InitOptions) (machine.VM, error) { // LoadByName reads a json file that describes a known qemu vm // and returns a vm instance -func LoadVMByName(name string) (machine.VM, error) { +func (p *Provider) LoadVMByName(name string) (machine.VM, error) { vm := new(MachineVM) vmConfigDir, err := machine.GetConfDir(vmtype) if err != nil { @@ -126,7 +124,7 @@ func LoadVMByName(name string) (machine.VM, error) { // Init writes the json configuration file to the filesystem for // other verbs (start, stop) -func (v *MachineVM) Init(opts machine.InitOptions) error { +func (v *MachineVM) Init(opts machine.InitOptions) (bool, error) { var ( key string ) @@ -135,7 +133,7 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { // its existence vmConfigDir, err := machine.GetConfDir(vmtype) if err != nil { - return err + return false, err } jsonFile := filepath.Join(vmConfigDir, v.Name) + ".json" v.IdentityPath = filepath.Join(sshDir, v.Name) @@ -147,11 +145,11 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { dd, err := machine.NewFcosDownloader(vmtype, v.Name, opts.ImagePath) if err != nil { - return err + return false, err } v.ImagePath = dd.Get().LocalUncompressedFile - if err := dd.DownloadImage(); err != nil { - return err + if err := machine.DownloadImage(dd); err != nil { + return false, err } default: // The user has provided an alternate image which can be a file path @@ -159,11 +157,11 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { v.ImageStream = "custom" g, err := machine.NewGenericDownloader(vmtype, v.Name, opts.ImagePath) if err != nil { - return err + return false, err } v.ImagePath = g.Get().LocalUncompressedFile - if err := g.DownloadImage(); err != nil { - return err + if err := machine.DownloadImage(g); err != nil { + return false, err } } // Add arch specific options including image location @@ -175,12 +173,12 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { if len(opts.IgnitionPath) < 1 { uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/user/1000/podman/podman.sock", strconv.Itoa(v.Port), v.RemoteUsername) if err := machine.AddConnection(&uri, v.Name, filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil { - return err + return false, err } uriRoot := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/podman/podman.sock", strconv.Itoa(v.Port), "root") if err := machine.AddConnection(&uriRoot, v.Name+"-root", filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil { - return err + return false, err } } else { fmt.Println("An ignition path was provided. No SSH connection was added to Podman") @@ -188,10 +186,10 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { // Write the JSON file b, err := json.MarshalIndent(v, "", " ") if err != nil { - return err + return false, err } if err := ioutil.WriteFile(jsonFile, b, 0644); err != nil { - return err + return false, err } // User has provided ignition file so keygen @@ -199,17 +197,17 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { if len(opts.IgnitionPath) < 1 { key, err = machine.CreateSSHKeys(v.IdentityPath) if err != nil { - return err + return false, err } } // Run arch specific things that need to be done if err := v.prepare(); err != nil { - return err + return false, err } originalDiskSize, err := getDiskSize(v.ImagePath) if err != nil { - return err + return false, err } // Resize the disk image to input disk size // only if the virtualdisk size is less than @@ -219,7 +217,7 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { resize.Stdout = os.Stdout resize.Stderr = os.Stderr if err := resize.Run(); err != nil { - return errors.Errorf("error resizing image: %q", err) + return false, errors.Errorf("error resizing image: %q", err) } } // If the user provides an ignition file, we need to @@ -227,9 +225,9 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { if len(opts.IgnitionPath) > 0 { inputIgnition, err := ioutil.ReadFile(opts.IgnitionPath) if err != nil { - return err + return false, err } - return ioutil.WriteFile(v.IgnitionFilePath, inputIgnition, 0644) + return false, ioutil.WriteFile(v.IgnitionFilePath, inputIgnition, 0644) } // Write the ignition file ign := machine.DynamicIgnition{ @@ -239,7 +237,8 @@ func (v *MachineVM) Init(opts machine.InitOptions) error { TimeZone: opts.TimeZone, WritePath: v.IgnitionFilePath, } - return machine.NewIgnitionFile(ign) + err = machine.NewIgnitionFile(ign) + return err == nil, err } // Start executes the qemu command line and forks it @@ -571,7 +570,7 @@ func getDiskSize(path string) (uint64, error) { } // List lists all vm's that use qemu virtualization -func List(_ machine.ListOptions) ([]*machine.ListResponse, error) { +func (p *Provider) List(_ machine.ListOptions) ([]*machine.ListResponse, error) { return GetVMInfos() } @@ -601,8 +600,8 @@ func GetVMInfos() ([]*machine.ListResponse, error) { listEntry.Stream = vm.ImageStream listEntry.VMType = "qemu" listEntry.CPUs = vm.CPUs - listEntry.Memory = vm.Memory - listEntry.DiskSize = vm.DiskSize + listEntry.Memory = vm.Memory * units.MiB + listEntry.DiskSize = vm.DiskSize * units.GiB fi, err := os.Stat(fullPath) if err != nil { return err @@ -627,7 +626,7 @@ func GetVMInfos() ([]*machine.ListResponse, error) { return listed, err } -func IsValidVMName(name string) (bool, error) { +func (p *Provider) IsValidVMName(name string) (bool, error) { infos, err := GetVMInfos() if err != nil { return false, err @@ -640,8 +639,9 @@ func IsValidVMName(name string) (bool, error) { return false, nil } -// CheckActiveVM checks if there is a VM already running -func CheckActiveVM() (bool, string, error) { +// CheckExclusiveActiveVM checks if there is a VM already running +// that does not allow other VMs to be running +func (p *Provider) CheckExclusiveActiveVM() (bool, string, error) { vms, err := GetVMInfos() if err != nil { return false, "", errors.Wrap(err, "error checking VM active") diff --git a/pkg/machine/qemu/machine_unsupported.go b/pkg/machine/qemu/machine_unsupported.go index da06ac324..e3ce05e3d 100644 --- a/pkg/machine/qemu/machine_unsupported.go +++ b/pkg/machine/qemu/machine_unsupported.go @@ -1,3 +1,3 @@ -// +build !amd64 amd64,windows +// +build !amd64,!arm64 windows package qemu diff --git a/pkg/machine/wsl/machine.go b/pkg/machine/wsl/machine.go new file mode 100644 index 000000000..b4ee79acc --- /dev/null +++ b/pkg/machine/wsl/machine.go @@ -0,0 +1,1119 @@ +//go:build windows +// +build windows + +package wsl + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/containers/podman/v3/pkg/machine" + "github.com/containers/podman/v3/utils" + "github.com/containers/storage/pkg/homedir" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +var ( + wslProvider = &Provider{} + // vmtype refers to qemu (vs libvirt, krun, etc) + vmtype = "wsl" +) + +const ( + ErrorSuccessRebootInitiated = 1641 + ErrorSuccessRebootRequired = 3010 +) + +// Usermode networking avoids potential nftables compatibility issues between the distro +// and the WSL Kernel. Additionally it avoids fw rule conflicts between distros, since +// all instances run under the same Kernel at runtime +const containersConf = `[containers] + +[engine] +cgroup_manager = "cgroupfs" +events_logger = "file" +` + +const appendPort = `grep -q Port\ %d /etc/ssh/sshd_config || echo Port %d >> /etc/ssh/sshd_config` + +const configServices = `ln -fs /usr/lib/systemd/system/sshd.service /etc/systemd/system/multi-user.target.wants/sshd.service +ln -fs /usr/lib/systemd/system/podman.socket /etc/systemd/system/sockets.target.wants/podman.socket +rm -f /etc/systemd/system/getty.target.wants/console-getty.service +rm -f /etc/systemd/system/getty.target.wants/getty@tty1.service +rm -f /etc/systemd/system/multi-user.target.wants/systemd-resolved.service +rm -f /etc/systemd/system/dbus-org.freedesktop.resolve1.service +ln -fs /dev/null /etc/systemd/system/console-getty.service +mkdir -p /etc/systemd/system/systemd-sysusers.service.d/ +adduser -m [USER] -G wheel +mkdir -p /home/[USER]/.config/systemd/[USER]/ +chown [USER]:[USER] /home/[USER]/.config +` + +const sudoers = `%wheel ALL=(ALL) NOPASSWD: ALL +` + +const bootstrap = `#!/bin/bash +ps -ef | grep -v grep | grep -q systemd && exit 0 +nohup unshare --kill-child --fork --pid --mount --mount-proc --propagation shared /lib/systemd/systemd >/dev/null 2>&1 & +sleep 0.1 +` + +const wslmotd = ` +You will be automatically entered into a nested process namespace where +systemd is running. If you need to access the parent namespace, hit ctrl-d +or type exit. This also means to log out you need to exit twice. + +` + +const sysdpid = "SYSDPID=`ps -eo cmd,pid | grep -m 1 ^/lib/systemd/systemd | awk '{print $2}'`" + +const profile = sysdpid + ` +if [ ! -z "$SYSDPID" ] && [ "$SYSDPID" != "1" ]; then + cat /etc/wslmotd + /usr/local/bin/enterns +fi +` + +const enterns = "#!/bin/bash\n" + sysdpid + ` +if [ ! -z "$SYSDPID" ] && [ "$SYSDPID" != "1" ]; then + nsenter -m -p -t $SYSDPID "$@" +fi +` + +const waitTerm = sysdpid + ` +if [ ! -z "$SYSDPID" ]; then + timeout 60 tail -f /dev/null --pid $SYSDPID +fi +` + +// WSL kernel does not have sg and crypto_user modules +const overrideSysusers = `[Service] +LoadCredential= +` + +const lingerService = `[Unit] +Description=A systemd user unit demo +After=network-online.target +Wants=network-online.target podman.socket +[Service] +ExecStart=/usr/bin/sleep infinity +` + +const lingerSetup = `mkdir -p /home/[USER]/.config/systemd/[USER]/default.target.wants +ln -fs /home/[USER]/.config/systemd/[USER]/linger-example.service \ + /home/[USER]/.config/systemd/[USER]/default.target.wants/linger-example.service +` + +const wslInstallError = `Could not %s. See previous output for any potential failure details. +If you can not resolve the issue, and rerunning fails, try the "wsl --install" process +outlined in the following article: + +http://docs.microsoft.com/en-us/windows/wsl/install + +` + +const wslKernelError = `Could not %s. See previous output for any potential failure details. +If you can not resolve the issue, try rerunning the "podman machine init command". If that fails +try the "wsl --update" command and then rerun "podman machine init". Finally, if all else fails, +try following the steps outlined in the following article: + +http://docs.microsoft.com/en-us/windows/wsl/install + +` + +const wslInstallKernel = "install the WSL Kernel" + +const wslOldVersion = `Automatic installation of WSL can not be performed on this version of Windows +Either update to Build 19041 (or later), or perform the manual installation steps +outlined in the following article: + +http://docs.microsoft.com/en-us/windows/wsl/install\ + +` + +type Provider struct{} + +type MachineVM struct { + // IdentityPath is the fq path to the ssh priv key + IdentityPath string + // IgnitionFilePath is the fq path to the .ign file + ImageStream string + // ImagePath is the fq path to + ImagePath string + // Name of the vm + Name string + // SSH port for user networking + Port int + // RemoteUsername of the vm user + RemoteUsername string +} + +type ExitCodeError struct { + code uint +} + +func (e *ExitCodeError) Error() string { + return fmt.Sprintf("Process failed with exit code: %d", e.code) +} + +func GetWSLProvider() machine.Provider { + return wslProvider +} + +// NewMachine initializes an instance of a virtual machine based on the qemu +// virtualization. +func (p *Provider) NewMachine(opts machine.InitOptions) (machine.VM, error) { + vm := new(MachineVM) + if len(opts.Name) > 0 { + vm.Name = opts.Name + } + + vm.ImagePath = opts.ImagePath + vm.RemoteUsername = opts.Username + + // Add a random port for ssh + port, err := utils.GetRandomPort() + if err != nil { + return nil, err + } + vm.Port = port + + return vm, nil +} + +// LoadByName reads a json file that describes a known qemu vm +// and returns a vm instance +func (p *Provider) LoadVMByName(name string) (machine.VM, error) { + vm := new(MachineVM) + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return nil, err + } + b, err := ioutil.ReadFile(filepath.Join(vmConfigDir, name+".json")) + if os.IsNotExist(err) { + return nil, errors.Wrap(machine.ErrNoSuchVM, name) + } + if err != nil { + return nil, err + } + err = json.Unmarshal(b, vm) + return vm, err +} + +// Init writes the json configuration file to the filesystem for +// other verbs (start, stop) +func (v *MachineVM) Init(opts machine.InitOptions) (bool, error) { + if cont, err := checkAndInstallWSL(opts); !cont { + appendOutputIfError(opts.ReExec, err) + return cont, err + } + + homeDir := homedir.Get() + sshDir := filepath.Join(homeDir, ".ssh") + v.IdentityPath = filepath.Join(sshDir, v.Name) + + if err := downloadDistro(v, opts); err != nil { + return false, err + } + + if err := writeJSON(v); err != nil { + return false, err + } + + if err := setupConnections(v, opts, sshDir); err != nil { + return false, err + } + + dist, err := provisionWSLDist(v) + if err != nil { + return false, err + } + + fmt.Println("Configuring system...") + if err = configureSystem(v, dist); err != nil { + return false, err + } + + if err = installScripts(dist); err != nil { + return false, err + } + + if err = createKeys(v, dist, sshDir); err != nil { + return false, err + } + + return true, nil +} + +func downloadDistro(v *MachineVM, opts machine.InitOptions) error { + var ( + dd machine.DistributionDownload + err error + ) + + if _, e := strconv.Atoi(opts.ImagePath); e == nil { + v.ImageStream = opts.ImagePath + dd, err = machine.NewFedoraDownloader(vmtype, v.Name, v.ImageStream) + } else { + v.ImageStream = "custom" + dd, err = machine.NewGenericDownloader(vmtype, v.Name, opts.ImagePath) + } + if err != nil { + return err + } + + v.ImagePath = dd.Get().LocalUncompressedFile + return machine.DownloadImage(dd) +} + +func writeJSON(v *MachineVM) error { + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return err + } + + jsonFile := filepath.Join(vmConfigDir, v.Name) + ".json" + + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + if err := ioutil.WriteFile(jsonFile, b, 0644); err != nil { + return errors.Wrap(err, "could not write machine json config") + } + + return nil +} + +func setupConnections(v *MachineVM, opts machine.InitOptions, sshDir string) error { + uriRoot := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/podman/podman.sock", strconv.Itoa(v.Port), "root") + if err := machine.AddConnection(&uriRoot, v.Name+"-root", filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil { + return err + } + + user := opts.Username + uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", withUser("/run/[USER]/1000/podman/podman.sock", user), strconv.Itoa(v.Port), v.RemoteUsername) + return machine.AddConnection(&uri, v.Name, filepath.Join(sshDir, v.Name), opts.IsDefault) +} + +func provisionWSLDist(v *MachineVM) (string, error) { + vmDataDir, err := machine.GetDataDir(vmtype) + if err != nil { + return "", err + } + + distDir := filepath.Join(vmDataDir, "wsldist") + distTarget := filepath.Join(distDir, v.Name) + if err := os.MkdirAll(distDir, 0755); err != nil { + return "", errors.Wrap(err, "could not create wsldist directory") + } + + dist := toDist(v.Name) + fmt.Println("Importing operating system into WSL (this may take 5+ minutes on a new WSL install)...") + if err = runCmdPassThrough("wsl", "--import", dist, distTarget, v.ImagePath); err != nil { + return "", errors.Wrap(err, "WSL import of guest OS failed") + } + + fmt.Println("Installing packages (this will take awhile)...") + if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "upgrade", "-y"); err != nil { + return "", errors.Wrap(err, "package upgrade on guest OS failed") + } + + if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "install", + "podman", "podman-docker", "openssh-server", "procps-ng", "-y"); err != nil { + return "", errors.Wrap(err, "package installation on guest OS failed") + } + + // Fixes newuidmap + if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "reinstall", "shadow-utils", "-y"); err != nil { + return "", errors.Wrap(err, "package reinstallation of shadow-utils on guest OS failed") + } + + // Windows 11 (NT Version = 10, Build 22000) generates harmless but scary messages on every + // operation when mount was not present on the initial start. Force a cycle so that it won't + // repeatedly complain. + if winVersionAtLeast(10, 0, 22000) { + if err := runCmdPassThrough("wsl", "--terminate", dist); err != nil { + logrus.Warnf("could not cycle WSL dist: %s", err.Error()) + } + } + + return dist, nil +} + +func createKeys(v *MachineVM, dist string, sshDir string) error { + user := v.RemoteUsername + + if err := os.MkdirAll(sshDir, 0700); err != nil { + return errors.Wrap(err, "could not create ssh directory") + } + + if err := runCmdPassThrough("wsl", "--terminate", dist); err != nil { + return errors.Wrap(err, "could not cycle WSL dist") + } + + key, err := machine.CreateSSHKeysPrefix(sshDir, v.Name, true, true, "wsl", "-d", dist) + if err != nil { + return errors.Wrap(err, "could not create ssh keys") + } + + if err := pipeCmdPassThrough("wsl", key+"\n", "-d", dist, "sh", "-c", "mkdir -p /root/.ssh;"+ + "cat >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys"); err != nil { + return errors.Wrap(err, "could not create root authorized keys on guest OS") + } + + userAuthCmd := withUser("mkdir -p /home/[USER]/.ssh;"+ + "cat >> /home/[USER]/.ssh/authorized_keys; chown -R [USER]:[USER] /home/[USER]/.ssh;"+ + "chmod 600 /home/[USER]/.ssh/authorized_keys", user) + if err := pipeCmdPassThrough("wsl", key+"\n", "-d", dist, "sh", "-c", userAuthCmd); err != nil { + return errors.Wrapf(err, "could not create '%s' authorized keys on guest OS", v.RemoteUsername) + } + + return nil +} + +func configureSystem(v *MachineVM, dist string) error { + user := v.RemoteUsername + if err := runCmdPassThrough("wsl", "-d", dist, "sh", "-c", fmt.Sprintf(appendPort, v.Port, v.Port)); err != nil { + return errors.Wrap(err, "could not configure SSH port for guest OS") + } + + if err := pipeCmdPassThrough("wsl", withUser(configServices, user), "-d", dist, "sh"); err != nil { + return errors.Wrap(err, "could not configure systemd settings for guest OS") + } + + if err := pipeCmdPassThrough("wsl", sudoers, "-d", dist, "sh", "-c", "cat >> /etc/sudoers"); err != nil { + return errors.Wrap(err, "could not add wheel to sudoers") + } + + if err := pipeCmdPassThrough("wsl", overrideSysusers, "-d", dist, "sh", "-c", + "cat > /etc/systemd/system/systemd-sysusers.service.d/override.conf"); err != nil { + return errors.Wrap(err, "could not generate systemd-sysusers override for guest OS") + } + + lingerCmd := withUser("cat > /home/[USER]/.config/systemd/[USER]/linger-example.service", user) + if err := pipeCmdPassThrough("wsl", lingerService, "-d", dist, "sh", "-c", lingerCmd); err != nil { + return errors.Wrap(err, "could not generate linger service for guest OS") + } + + if err := pipeCmdPassThrough("wsl", withUser(lingerSetup, user), "-d", dist, "sh"); err != nil { + return errors.Wrap(err, "could not configure systemd settomgs for guest OS") + } + + if err := pipeCmdPassThrough("wsl", containersConf, "-d", dist, "sh", "-c", "cat > /etc/containers/containers.conf"); err != nil { + return errors.Wrap(err, "could not create containers.conf for guest OS") + } + + return nil +} + +func installScripts(dist string) error { + if err := pipeCmdPassThrough("wsl", enterns, "-d", dist, "sh", "-c", + "cat > /usr/local/bin/enterns; chmod 755 /usr/local/bin/enterns"); err != nil { + return errors.Wrap(err, "could not create enterns script for guest OS") + } + + if err := pipeCmdPassThrough("wsl", profile, "-d", dist, "sh", "-c", + "cat > /etc/profile.d/enterns.sh"); err != nil { + return errors.Wrap(err, "could not create motd profile script for guest OS") + } + + if err := pipeCmdPassThrough("wsl", wslmotd, "-d", dist, "sh", "-c", "cat > /etc/wslmotd"); err != nil { + return errors.Wrap(err, "could not create a WSL MOTD for guest OS") + } + + if err := pipeCmdPassThrough("wsl", bootstrap, "-d", dist, "sh", "-c", + "cat > /root/bootstrap; chmod 755 /root/bootstrap"); err != nil { + return errors.Wrap(err, "could not create bootstrap script for guest OS") + } + + return nil +} + +func checkAndInstallWSL(opts machine.InitOptions) (bool, error) { + if isWSLInstalled() { + return true, nil + } + + admin := hasAdminRights() + + if !isWSLFeatureEnabled() { + return false, attemptFeatureInstall(opts, admin) + } + + skip := false + if !opts.ReExec && !admin { + fmt.Println("Launching WSL Kernel Install...") + if err := launchElevate(wslInstallKernel); err != nil { + return false, err + } + + skip = true + } + + if !skip { + if err := installWslKernel(); err != nil { + fmt.Fprintf(os.Stderr, wslKernelError, wslInstallKernel) + return false, err + } + + if opts.ReExec { + return false, nil + } + } + + return true, nil +} + +func attemptFeatureInstall(opts machine.InitOptions, admin bool) error { + if !winVersionAtLeast(10, 0, 18362) { + return errors.Errorf("Your version of Windows does not support WSL. Update to Windows 10 Build 19041 or later") + } else if !winVersionAtLeast(10, 0, 19041) { + fmt.Fprint(os.Stderr, wslOldVersion) + return errors.Errorf("WSL can not be automatically installed") + } + + message := "WSL is not installed on this system, installing it.\n\n" + + if !admin { + message += "Since you are not running as admin, a new window will open and " + + "require you to approve administrator privileges.\n\n" + } + + message += "NOTE: A system reboot will be required as part of this process. " + + "If you prefer, you may abort now, and perform a manual installation using the \"wsl --install\" command." + + if !opts.ReExec && MessageBox(message, "Podman Machine", false) != 1 { + return errors.Errorf("WSL installation aborted") + } + + if !opts.ReExec && !admin { + return launchElevate("install the Windows WSL Features") + } + + return installWsl() +} + +func launchElevate(operation string) error { + truncateElevatedOutputFile() + err := relaunchElevatedWait() + if err != nil { + if eerr, ok := err.(*ExitCodeError); ok { + if eerr.code == ErrorSuccessRebootRequired { + fmt.Println("Reboot is required to continue installation, please reboot at your convenience") + return nil + } + } + + fmt.Fprintf(os.Stderr, "Elevated process failed with error: %v\n\n", err) + dumpOutputFile() + fmt.Fprintf(os.Stderr, wslInstallError, operation) + } + return err +} + +func installWsl() error { + log, err := getElevatedOutputFileWrite() + if err != nil { + return err + } + defer log.Close() + if err := runCmdPassThroughTee(log, "dism", "/online", "/enable-feature", + "/featurename:Microsoft-Windows-Subsystem-Linux", "/all", "/norestart"); isMsiError(err) { + return errors.Wrap(err, "could not enable WSL Feature") + } + + if err = runCmdPassThroughTee(log, "dism", "/online", "/enable-feature", + "/featurename:VirtualMachinePlatform", "/all", "/norestart"); isMsiError(err) { + return errors.Wrap(err, "could not enable Virtual Machine Feature") + } + log.Close() + + return reboot() +} + +func installWslKernel() error { + log, err := getElevatedOutputFileWrite() + if err != nil { + return err + } + defer log.Close() + + message := "Installing WSL Kernel Update" + fmt.Println(message) + fmt.Fprintln(log, message) + + backoff := 500 * time.Millisecond + for i := 0; i < 5; i++ { + err = runCmdPassThroughTee(log, "wsl", "--update") + if err == nil { + break + } + // In case of unusual circumstances (e.g. race with installer actions) + // retry a few times + message = "An error occured attempting the WSL Kernel update, retrying..." + fmt.Println(message) + fmt.Fprintln(log, message) + time.Sleep(backoff) + backoff *= 2 + } + + if err != nil { + return errors.Wrap(err, "could not install WSL Kernel") + } + + return nil +} + +func getElevatedOutputFileName() (string, error) { + dir, err := homedir.GetDataHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "podman-elevated-output.log"), nil +} + +func dumpOutputFile() { + file, err := getElevatedOutputFileRead() + if err != nil { + logrus.Debug("could not find elevated child output file") + return + } + defer file.Close() + _, _ = io.Copy(os.Stdout, file) +} + +func getElevatedOutputFileRead() (*os.File, error) { + return getElevatedOutputFile(os.O_RDONLY) +} + +func getElevatedOutputFileWrite() (*os.File, error) { + return getElevatedOutputFile(os.O_WRONLY | os.O_CREATE | os.O_APPEND) +} + +func appendOutputIfError(write bool, err error) { + if write && err == nil { + return + } + + if file, check := getElevatedOutputFileWrite(); check == nil { + defer file.Close() + fmt.Fprintf(file, "Error: %v\n", err) + } +} + +func truncateElevatedOutputFile() error { + name, err := getElevatedOutputFileName() + if err != nil { + return err + } + + return os.Truncate(name, 0) +} + +func getElevatedOutputFile(mode int) (*os.File, error) { + name, err := getElevatedOutputFileName() + if err != nil { + return nil, err + } + + dir, err := homedir.GetDataHome() + if err != nil { + return nil, err + } + + if err = os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + return os.OpenFile(name, mode, 0644) +} + +func isMsiError(err error) bool { + if err == nil { + return false + } + + if eerr, ok := err.(*exec.ExitError); ok { + switch eerr.ExitCode() { + case 0: + fallthrough + case ErrorSuccessRebootInitiated: + fallthrough + case ErrorSuccessRebootRequired: + return false + } + } + + return true +} +func toDist(name string) string { + if !strings.HasPrefix(name, "podman") { + name = "podman-" + name + } + return name +} + +func withUser(s string, user string) string { + return strings.ReplaceAll(s, "[USER]", user) +} + +func runCmdPassThrough(name string, arg ...string) error { + logrus.Debugf("Running command: %s %v", name, arg) + cmd := exec.Command(name, arg...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runCmdPassThroughTee(out io.Writer, name string, arg ...string) error { + logrus.Debugf("Running command: %s %v", name, arg) + + // TODO - Perhaps improve this with a conpty pseudo console so that + // dism installer text bars mirror console behavior (redraw) + cmd := exec.Command(name, arg...) + cmd.Stdin = os.Stdin + cmd.Stdout = io.MultiWriter(os.Stdout, out) + cmd.Stderr = io.MultiWriter(os.Stderr, out) + return cmd.Run() +} + +func pipeCmdPassThrough(name string, input string, arg ...string) error { + logrus.Debugf("Running command: %s %v", name, arg) + cmd := exec.Command(name, arg...) + cmd.Stdin = strings.NewReader(input) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (v *MachineVM) Start(name string, _ machine.StartOptions) error { + if v.isRunning() { + return errors.Errorf("%q is already running", name) + } + + fmt.Println("Starting machine...") + + dist := toDist(name) + + err := runCmdPassThrough("wsl", "-d", dist, "/root/bootstrap") + if err != nil { + return errors.Wrap(err, "WSL bootstrap script failed") + } + + return markStart(name) +} + +func isWSLInstalled() bool { + cmd := exec.Command("wsl", "--status") + out, err := cmd.StdoutPipe() + if err != nil { + return false + } + if err = cmd.Start(); err != nil { + return false + } + scanner := bufio.NewScanner(transform.NewReader(out, unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder())) + result := true + for scanner.Scan() { + line := scanner.Text() + // Windows 11 does not set an error exit code when a kernel is not avail + if strings.Contains(line, "kernel file is not found") { + result = false + break + } + } + if err := cmd.Wait(); !result || err != nil { + return false + } + + return true +} + +func isWSLFeatureEnabled() bool { + cmd := exec.Command("wsl", "--set-default-version", "2") + return cmd.Run() == nil +} + +func isWSLRunning(dist string) (bool, error) { + cmd := exec.Command("wsl", "-l", "--running") + out, err := cmd.StdoutPipe() + if err != nil { + return false, err + } + if err = cmd.Start(); err != nil { + return false, err + } + scanner := bufio.NewScanner(transform.NewReader(out, unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder())) + result := false + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) > 0 && dist == fields[0] { + result = true + break + } + } + + _ = cmd.Wait() + + return result, nil +} + +func isSystemdRunning(dist string) (bool, error) { + cmd := exec.Command("wsl", "-d", dist, "sh") + cmd.Stdin = strings.NewReader(sysdpid + "\necho $SYSDPID\n") + out, err := cmd.StdoutPipe() + if err != nil { + return false, err + } + if err = cmd.Start(); err != nil { + return false, err + } + scanner := bufio.NewScanner(out) + result := false + if scanner.Scan() { + text := scanner.Text() + i, err := strconv.Atoi(text) + if err == nil && i > 0 { + result = true + } + } + + _ = cmd.Wait() + + return result, nil +} + +func (v *MachineVM) Stop(name string, _ machine.StopOptions) error { + dist := toDist(v.Name) + + wsl, err := isWSLRunning(dist) + if err != nil { + return err + } + + sysd := false + if wsl { + sysd, err = isSystemdRunning(dist) + if err != nil { + return err + } + } + + if !wsl || !sysd { + return errors.Errorf("%q is not running", v.Name) + } + + cmd := exec.Command("wsl", "-d", dist, "sh") + cmd.Stdin = strings.NewReader(waitTerm) + if err = cmd.Start(); err != nil { + return errors.Wrap(err, "Error executing wait command") + } + + exitCmd := exec.Command("wsl", "-d", dist, "/usr/local/bin/enterns", "systemctl", "exit", "0") + if err = exitCmd.Run(); err != nil { + return errors.Wrap(err, "Error stopping sysd") + } + + if err = cmd.Wait(); err != nil { + return err + } + + cmd = exec.Command("wsl", "--terminate", dist) + if err = cmd.Run(); err != nil { + return err + } + + return nil +} + +//nolint:cyclop +func (v *MachineVM) Remove(name string, opts machine.RemoveOptions) (string, func() error, error) { + var files []string + + if v.isRunning() { + return "", nil, errors.Errorf("running vm %q cannot be destroyed", v.Name) + } + + // Collect all the files that need to be destroyed + if !opts.SaveKeys { + files = append(files, v.IdentityPath, v.IdentityPath+".pub") + } + if !opts.SaveImage { + files = append(files, v.ImagePath) + } + + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return "", nil, err + } + files = append(files, filepath.Join(vmConfigDir, v.Name+".json")) + + vmDataDir, err := machine.GetDataDir(vmtype) + if err != nil { + return "", nil, err + } + files = append(files, filepath.Join(vmDataDir, "wsldist", v.Name)) + + confirmationMessage := "\nThe following files will be deleted:\n\n" + for _, msg := range files { + confirmationMessage += msg + "\n" + } + + confirmationMessage += "\n" + return confirmationMessage, func() error { + if err := machine.RemoveConnection(v.Name); err != nil { + logrus.Error(err) + } + if err := machine.RemoveConnection(v.Name + "-root"); err != nil { + logrus.Error(err) + } + if err := runCmdPassThrough("wsl", "--unregister", toDist(v.Name)); err != nil { + logrus.Error(err) + } + for _, f := range files { + if err := os.RemoveAll(f); err != nil { + logrus.Error(err) + } + } + return nil + }, nil +} + +func (v *MachineVM) isRunning() bool { + dist := toDist(v.Name) + + wsl, err := isWSLRunning(dist) + if err != nil { + return false + } + + sysd := false + if wsl { + sysd, err = isSystemdRunning(dist) + + if err != nil { + return false + } + } + + return sysd +} + +// SSH opens an interactive SSH session to the vm specified. +// Added ssh function to VM interface: pkg/machine/config/go : line 58 +func (v *MachineVM) SSH(name string, opts machine.SSHOptions) error { + if !v.isRunning() { + return errors.Errorf("vm %q is not running.", v.Name) + } + + username := opts.Username + if username == "" { + username = v.RemoteUsername + } + + sshDestination := username + "@localhost" + port := strconv.Itoa(v.Port) + + args := []string{"-i", v.IdentityPath, "-p", port, sshDestination, "-o", "UserKnownHostsFile /dev/null", "-o", "StrictHostKeyChecking no"} + if len(opts.Args) > 0 { + args = append(args, opts.Args...) + } else { + fmt.Printf("Connecting to vm %s. To close connection, use `~.` or `exit`\n", v.Name) + } + + cmd := exec.Command("ssh", args...) + logrus.Debugf("Executing: ssh %v\n", args) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +// List lists all vm's that use qemu virtualization +func (p *Provider) List(_ machine.ListOptions) ([]*machine.ListResponse, error) { + return GetVMInfos() +} + +func GetVMInfos() ([]*machine.ListResponse, error) { + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return nil, err + } + + var listed []*machine.ListResponse + + if err = filepath.Walk(vmConfigDir, func(path string, info os.FileInfo, err error) error { + vm := new(MachineVM) + if strings.HasSuffix(info.Name(), ".json") { + fullPath := filepath.Join(vmConfigDir, info.Name()) + b, err := ioutil.ReadFile(fullPath) + if err != nil { + return err + } + err = json.Unmarshal(b, vm) + if err != nil { + return err + } + listEntry := new(machine.ListResponse) + + listEntry.Name = vm.Name + listEntry.Stream = vm.ImageStream + listEntry.VMType = "wsl" + listEntry.CPUs, _ = getCPUs(vm) + listEntry.Memory, _ = getMem(vm) + listEntry.DiskSize = getDiskSize(vm) + fi, err := os.Stat(fullPath) + if err != nil { + return err + } + listEntry.CreatedAt = fi.ModTime() + listEntry.LastUp = getLastStart(vm, fi.ModTime()) + if vm.isRunning() { + listEntry.Running = true + } + + listed = append(listed, listEntry) + } + return nil + }); err != nil { + return nil, err + } + return listed, err +} + +func getDiskSize(vm *MachineVM) uint64 { + vmDataDir, err := machine.GetDataDir(vmtype) + if err != nil { + return 0 + } + distDir := filepath.Join(vmDataDir, "wsldist") + disk := filepath.Join(distDir, vm.Name, "ext4.vhdx") + info, err := os.Stat(disk) + if err != nil { + return 0 + } + return uint64(info.Size()) +} + +func markStart(name string) error { + vmDataDir, err := machine.GetDataDir(vmtype) + if err != nil { + return err + } + distDir := filepath.Join(vmDataDir, "wsldist") + start := filepath.Join(distDir, name, "laststart") + file, err := os.Create(start) + if err != nil { + return err + } + file.Close() + + return nil +} + +func getLastStart(vm *MachineVM, created time.Time) time.Time { + vmDataDir, err := machine.GetDataDir(vmtype) + if err != nil { + return created + } + distDir := filepath.Join(vmDataDir, "wsldist") + start := filepath.Join(distDir, vm.Name, "laststart") + info, err := os.Stat(start) + if err != nil { + return created + } + return info.ModTime() +} + +func getCPUs(vm *MachineVM) (uint64, error) { + dist := toDist(vm.Name) + if run, _ := isWSLRunning(dist); !run { + return 0, nil + } + cmd := exec.Command("wsl", "-d", dist, "nproc") + out, err := cmd.StdoutPipe() + if err != nil { + return 0, err + } + if err = cmd.Start(); err != nil { + return 0, err + } + scanner := bufio.NewScanner(out) + var result string + for scanner.Scan() { + result = scanner.Text() + } + _ = cmd.Wait() + + ret, err := strconv.Atoi(result) + return uint64(ret), err +} + +func getMem(vm *MachineVM) (uint64, error) { + dist := toDist(vm.Name) + if run, _ := isWSLRunning(dist); !run { + return 0, nil + } + cmd := exec.Command("wsl", "-d", dist, "cat", "/proc/meminfo") + out, err := cmd.StdoutPipe() + if err != nil { + return 0, err + } + if err = cmd.Start(); err != nil { + return 0, err + } + scanner := bufio.NewScanner(out) + var ( + total, available uint64 + t, a int + ) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if strings.HasPrefix(fields[0], "MemTotal") && len(fields) >= 2 { + t, err = strconv.Atoi(fields[1]) + total = uint64(t) * 1024 + } else if strings.HasPrefix(fields[0], "MemAvailable") && len(fields) >= 2 { + a, err = strconv.Atoi(fields[1]) + available = uint64(a) * 1024 + } + if err != nil { + break + } + } + _ = cmd.Wait() + + return total - available, err +} + +func (p *Provider) IsValidVMName(name string) (bool, error) { + infos, err := GetVMInfos() + if err != nil { + return false, err + } + for _, vm := range infos { + if vm.Name == name { + return true, nil + } + } + return false, nil +} + +func (p *Provider) CheckExclusiveActiveVM() (bool, string, error) { + return false, "", nil +} diff --git a/pkg/machine/wsl/machine_unsupported.go b/pkg/machine/wsl/machine_unsupported.go new file mode 100644 index 000000000..043c5d729 --- /dev/null +++ b/pkg/machine/wsl/machine_unsupported.go @@ -0,0 +1,3 @@ +// +build !windows + +package wsl diff --git a/pkg/machine/wsl/util_windows.go b/pkg/machine/wsl/util_windows.go new file mode 100644 index 000000000..95e4c9894 --- /dev/null +++ b/pkg/machine/wsl/util_windows.go @@ -0,0 +1,338 @@ +package wsl + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "syscall" + "unicode/utf16" + "unsafe" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/containers/storage/pkg/homedir" +) + +//nolint +type SHELLEXECUTEINFO struct { + cbSize uint32 + fMask uint32 + hwnd syscall.Handle + lpVerb uintptr + lpFile uintptr + lpParameters uintptr + lpDirectory uintptr + nShow int + hInstApp syscall.Handle + lpIDList uintptr + lpClass uintptr + hkeyClass syscall.Handle + dwHotKey uint32 + hIconOrMonitor syscall.Handle + hProcess syscall.Handle +} + +//nolint +type Luid struct { + lowPart uint32 + highPart int32 +} + +type LuidAndAttributes struct { + luid Luid + attributes uint32 +} + +type TokenPrivileges struct { + privilegeCount uint32 + privileges [1]LuidAndAttributes +} + +//nolint // Cleaner to refer to the official OS constant names, and consistent with syscall +const ( + SEE_MASK_NOCLOSEPROCESS = 0x40 + EWX_FORCEIFHUNG = 0x10 + EWX_REBOOT = 0x02 + EWX_RESTARTAPPS = 0x40 + SHTDN_REASON_MAJOR_APPLICATION = 0x00040000 + SHTDN_REASON_MINOR_INSTALLATION = 0x00000002 + SHTDN_REASON_FLAG_PLANNED = 0x80000000 + TOKEN_ADJUST_PRIVILEGES = 0x0020 + TOKEN_QUERY = 0x0008 + SE_PRIVILEGE_ENABLED = 0x00000002 + SE_ERR_ACCESSDENIED = 0x05 +) + +func winVersionAtLeast(major uint, minor uint, build uint) bool { + var out [3]uint32 + + in := []uint32{uint32(major), uint32(minor), uint32(build)} + out[0], out[1], out[2] = windows.RtlGetNtVersionNumbers() + + for i, o := range out { + if in[i] > o { + return false + } + if in[i] < o { + return true + } + } + + return true +} + +func hasAdminRights() bool { + var sid *windows.SID + + // See: https://coolaj86.com/articles/golang-and-windows-and-admins-oh-my/ + if err := windows.AllocateAndInitializeSid( + &windows.SECURITY_NT_AUTHORITY, + 2, + windows.SECURITY_BUILTIN_DOMAIN_RID, + windows.DOMAIN_ALIAS_RID_ADMINS, + 0, 0, 0, 0, 0, 0, + &sid); err != nil { + logrus.Warnf("SID allocation error: %s", err) + return false + } + defer windows.FreeSid(sid) + + // From MS docs: + // "If TokenHandle is NULL, CheckTokenMembership uses the impersonation + // token of the calling thread. If the thread is not impersonating, + // the function duplicates the thread's primary token to create an + // impersonation token." + token := windows.Token(0) + + member, err := token.IsMember(sid) + if err != nil { + logrus.Warnf("Token Membership Error: %s", err) + return false + } + + return member || token.IsElevated() +} + +func relaunchElevatedWait() error { + e, _ := os.Executable() + d, _ := os.Getwd() + exe, _ := syscall.UTF16PtrFromString(e) + cwd, _ := syscall.UTF16PtrFromString(d) + arg, _ := syscall.UTF16PtrFromString(buildCommandArgs(true)) + verb, _ := syscall.UTF16PtrFromString("runas") + + shell32 := syscall.NewLazyDLL("shell32.dll") + + info := &SHELLEXECUTEINFO{ + fMask: SEE_MASK_NOCLOSEPROCESS, + hwnd: 0, + lpVerb: uintptr(unsafe.Pointer(verb)), + lpFile: uintptr(unsafe.Pointer(exe)), + lpParameters: uintptr(unsafe.Pointer(arg)), + lpDirectory: uintptr(unsafe.Pointer(cwd)), + nShow: 1, + } + info.cbSize = uint32(unsafe.Sizeof(*info)) + procShellExecuteEx := shell32.NewProc("ShellExecuteExW") + if ret, _, _ := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info))); ret == 0 { // 0 = False + err := syscall.GetLastError() + if info.hInstApp == SE_ERR_ACCESSDENIED { + return wrapMaybe(err, "request to elevate privileges was denied") + } + return wrapMaybef(err, "could not launch process, ShellEX Error = %d", info.hInstApp) + } + + handle := syscall.Handle(info.hProcess) + defer syscall.CloseHandle(handle) + + w, err := syscall.WaitForSingleObject(handle, syscall.INFINITE) + switch w { + case syscall.WAIT_OBJECT_0: + break + case syscall.WAIT_FAILED: + return errors.Wrap(err, "could not wait for process, failed") + default: + return errors.Errorf("could not wait for process, unknown error") + } + var code uint32 + if err := syscall.GetExitCodeProcess(handle, &code); err != nil { + return err + } + if code != 0 { + return &ExitCodeError{uint(code)} + } + + return nil +} + +func wrapMaybe(err error, message string) error { + if err != nil { + return errors.Wrap(err, message) + } + + return errors.New(message) +} + +func wrapMaybef(err error, format string, args ...interface{}) error { + if err != nil { + return errors.Wrapf(err, format, args...) + } + + return errors.Errorf(format, args...) +} + +func reboot() error { + const ( + wtLocation = `Microsoft\WindowsApps\wt.exe` + wtPrefix = `%LocalAppData%\Microsoft\WindowsApps\wt -p "Windows PowerShell" ` + localAppData = "LocalAppData" + pShellLaunch = `powershell -noexit "powershell -EncodedCommand (Get-Content '%s')"` + ) + + exe, _ := os.Executable() + relaunch := fmt.Sprintf("& %s %s", syscall.EscapeArg(exe), buildCommandArgs(false)) + encoded := base64.StdEncoding.EncodeToString(encodeUTF16Bytes(relaunch)) + + dataDir, err := homedir.GetDataHome() + if err != nil { + return errors.Wrap(err, "could not determine data directory") + } + if err := os.MkdirAll(dataDir, 0755); err != nil { + return errors.Wrap(err, "could not create data directory") + } + commFile := filepath.Join(dataDir, "podman-relaunch.dat") + if err := ioutil.WriteFile(commFile, []byte(encoded), 0600); err != nil { + return errors.Wrap(err, "could not serialize command state") + } + + command := fmt.Sprintf(pShellLaunch, commFile) + if _, err := os.Lstat(filepath.Join(os.Getenv(localAppData), wtLocation)); err == nil { + wtCommand := wtPrefix + command + // RunOnce is limited to 260 chars (supposedly no longer in Builds >= 19489) + // For now fallbacak in cases of long usernames (>89 chars) + if len(wtCommand) < 260 { + command = wtCommand + } + } + + if err := addRunOnceRegistryEntry(command); err != nil { + return err + } + + if err := obtainShutdownPrivilege(); err != nil { + return err + } + + message := "To continue the process of enabling WSL, the system needs to reboot. " + + "Alternatively, you can cancel and reboot manually\n\n" + + "After rebooting, please wait a minute or two for podman machine to relaunch and continue installing." + + if MessageBox(message, "Podman Machine", false) != 1 { + fmt.Println("Reboot is required to continue installation, please reboot at your convenience") + os.Exit(ErrorSuccessRebootRequired) + return nil + } + + user32 := syscall.NewLazyDLL("user32") + procExit := user32.NewProc("ExitWindowsEx") + if ret, _, err := procExit.Call(EWX_REBOOT|EWX_RESTARTAPPS|EWX_FORCEIFHUNG, + SHTDN_REASON_MAJOR_APPLICATION|SHTDN_REASON_MINOR_INSTALLATION|SHTDN_REASON_FLAG_PLANNED); ret != 1 { + return errors.Wrap(err, "reboot failed") + } + + return nil +} + +func obtainShutdownPrivilege() error { + const SeShutdownName = "SeShutdownPrivilege" + + advapi32 := syscall.NewLazyDLL("advapi32") + OpenProcessToken := advapi32.NewProc("OpenProcessToken") + LookupPrivilegeValue := advapi32.NewProc("LookupPrivilegeValueW") + AdjustTokenPrivileges := advapi32.NewProc("AdjustTokenPrivileges") + + proc, _ := syscall.GetCurrentProcess() + + var hToken uintptr + if ret, _, err := OpenProcessToken.Call(uintptr(proc), TOKEN_ADJUST_PRIVILEGES|TOKEN_QUERY, uintptr(unsafe.Pointer(&hToken))); ret != 1 { + return errors.Wrap(err, "error opening process token") + } + + var privs TokenPrivileges + if ret, _, err := LookupPrivilegeValue.Call(uintptr(0), uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(SeShutdownName))), uintptr(unsafe.Pointer(&(privs.privileges[0].luid)))); ret != 1 { + return errors.Wrap(err, "error looking up shutdown privilege") + } + + privs.privilegeCount = 1 + privs.privileges[0].attributes = SE_PRIVILEGE_ENABLED + + if ret, _, err := AdjustTokenPrivileges.Call(hToken, 0, uintptr(unsafe.Pointer(&privs)), 0, uintptr(0), 0); ret != 1 { + return errors.Wrap(err, "error enabling shutdown privilege on token") + } + + return nil +} + +func addRunOnceRegistryEntry(command string) error { + k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\RunOnce`, registry.WRITE) + if err != nil { + return errors.Wrap(err, "could not open RunOnce registry entry") + } + + defer k.Close() + + if err := k.SetExpandStringValue("podman-machine", command); err != nil { + return errors.Wrap(err, "could not open RunOnce registry entry") + } + + return nil +} + +func encodeUTF16Bytes(s string) []byte { + u16 := utf16.Encode([]rune(s)) + u16le := make([]byte, len(u16)*2) + for i := 0; i < len(u16); i++ { + u16le[i<<1] = byte(u16[i]) + u16le[(i<<1)+1] = byte(u16[i] >> 8) + } + return u16le +} + +func MessageBox(caption, title string, fail bool) int { + var format int + if fail { + format = 0x10 + } else { + format = 0x41 + } + + user32 := syscall.NewLazyDLL("user32.dll") + captionPtr, _ := syscall.UTF16PtrFromString(caption) + titlePtr, _ := syscall.UTF16PtrFromString(title) + ret, _, _ := user32.NewProc("MessageBoxW").Call( + uintptr(0), + uintptr(unsafe.Pointer(captionPtr)), + uintptr(unsafe.Pointer(titlePtr)), + uintptr(format)) + + return int(ret) +} + +func buildCommandArgs(elevate bool) string { + var args []string + for _, arg := range os.Args[1:] { + if arg != "--reexec" { + args = append(args, syscall.EscapeArg(arg)) + if elevate && arg == "init" { + args = append(args, "--reexec") + } + } + } + return strings.Join(args, " ") +} |