minikube/pkg/drivers/vfkit/vfkit.go

658 lines
16 KiB
Go

//go:build darwin
/*
Copyright 2024 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package vfkit
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/docker/machine/libmachine/drivers"
"github.com/docker/machine/libmachine/log"
"github.com/docker/machine/libmachine/mcnutils"
"github.com/docker/machine/libmachine/ssh"
"github.com/docker/machine/libmachine/state"
"github.com/pkg/errors"
"k8s.io/klog/v2"
"k8s.io/minikube/pkg/drivers/common"
"k8s.io/minikube/pkg/drivers/common/vmnet"
"k8s.io/minikube/pkg/minikube/detect"
"k8s.io/minikube/pkg/minikube/exit"
"k8s.io/minikube/pkg/minikube/firewall"
"k8s.io/minikube/pkg/minikube/out"
"k8s.io/minikube/pkg/minikube/process"
"k8s.io/minikube/pkg/minikube/reason"
"k8s.io/minikube/pkg/minikube/style"
)
const (
isoFilename = "boot2docker.iso"
pidFileName = "vfkit.pid"
sockFilename = "vfkit.sock"
logFileName = "vfkit.log"
serialFileName = "serial.log"
defaultSSHUser = "docker"
)
// Driver is the machine driver for vfkit (Virtualization.framework)
type Driver struct {
*drivers.BaseDriver
*common.CommonDriver
Boot2DockerURL string
DiskSize int
CPU int
Memory int
ExtraDisks int
Network string // "", "nat", "vmnet-shared"
MACAddress string // For network=nat, network=""
VmnetHelper *vmnet.Helper // For network=vmnet-shared
}
func NewDriver(hostName, storePath string) drivers.Driver {
return &Driver{
BaseDriver: &drivers.BaseDriver{
SSHUser: defaultSSHUser,
MachineName: hostName,
StorePath: storePath,
},
CommonDriver: &common.CommonDriver{},
}
}
func (d *Driver) PreCreateCheck() error {
return nil
}
func (d *Driver) GetMachineName() string {
return d.MachineName
}
func (d *Driver) DriverName() string {
return "vfkit"
}
func (d *Driver) GetSSHHostname() (string, error) {
return d.IPAddress, nil
}
func (d *Driver) GetSSHKeyPath() string {
return d.ResolveStorePath("id_rsa")
}
func (d *Driver) GetSSHPort() (int, error) {
if d.SSHPort == 0 {
d.SSHPort = 22
}
return d.SSHPort, nil
}
func (d *Driver) GetSSHUsername() string {
if d.SSHUser == "" {
d.SSHUser = defaultSSHUser
}
return d.SSHUser
}
func (d *Driver) GetURL() (string, error) {
if _, err := os.Stat(d.pidfilePath()); err != nil {
return "", nil
}
ip, err := d.GetIP()
if err != nil {
log.Warnf("Failed to get IP: %v", err)
return "", err
}
if ip == "" {
return "", nil
}
return fmt.Sprintf("tcp://%s:2376", ip), nil
}
func (d *Driver) GetIP() (string, error) {
return d.IPAddress, nil
}
func (d *Driver) getVfkitState() (state.State, error) {
pidfile := d.pidfilePath()
pid, err := process.ReadPidfile(pidfile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return state.Error, err
}
return state.Stopped, nil
}
exists, err := process.Exists(pid, "vfkit")
if err != nil {
return state.Error, err
}
if !exists {
// No process, stale pidfile.
if err := os.Remove(pidfile); err != nil {
log.Debugf("failed to remove %q: %s", pidfile, err)
}
return state.Stopped, nil
}
return state.Running, nil
}
func (d *Driver) getVmnetHelperState() (state.State, error) {
if d.VmnetHelper == nil {
return state.Stopped, nil
}
return d.VmnetHelper.GetState()
}
// GetState returns driver state. Since vfkit driver may use 2 processes
// (vmnet-helper, vfkit), this returns combined state of both processes.
func (d *Driver) GetState() (state.State, error) {
if vfkitState, err := d.getVfkitState(); err != nil {
return state.Error, err
} else if vfkitState == state.Running {
return state.Running, nil
}
return d.getVmnetHelperState()
}
func (d *Driver) Create() error {
var err error
if d.SSHPort, err = d.GetSSHPort(); err != nil {
return err
}
b2dutils := mcnutils.NewB2dUtils(d.StorePath)
if err := b2dutils.CopyIsoToMachineDir(d.Boot2DockerURL, d.MachineName); err != nil {
return err
}
if err := d.extractKernel(); err != nil {
return err
}
log.Info("Creating SSH key...")
if err := ssh.GenerateSSHKey(d.sshKeyPath()); err != nil {
return err
}
log.Info("Creating Disk image...")
if err := d.generateDiskImage(d.DiskSize); err != nil {
return err
}
if d.ExtraDisks > 0 {
log.Info("Creating extra disk images...")
for i := 0; i < d.ExtraDisks; i++ {
path := common.ExtraDiskPath(d.BaseDriver, i)
if err := common.CreateRawDisk(path, d.DiskSize); err != nil {
return err
}
}
}
log.Info("Starting vfkit VM...")
return d.Start()
}
func (d *Driver) extractKernel() error {
log.Info("Extracting bzimage and initrd...")
if err := common.ExtractFile(d.isoPath(), "/boot/bzimage", d.kernelPath()); err != nil {
return err
}
return common.ExtractFile(d.isoPath(), "/boot/initrd", d.initrdPath())
}
func (d *Driver) Start() error {
var socketPath string
if d.VmnetHelper != nil {
socketPath = d.VmnetHelper.SocketPath()
if err := d.VmnetHelper.Start(socketPath); err != nil {
return err
}
d.MACAddress = d.VmnetHelper.GetMACAddress()
}
if err := d.startVfkit(socketPath); err != nil {
return err
}
if err := d.setupIP(d.MACAddress); err != nil {
return err
}
log.Infof("Waiting for VM to start (ssh -p %d docker@%s)...", d.SSHPort, d.IPAddress)
return WaitForTCPWithDelay(fmt.Sprintf("%s:%d", d.IPAddress, d.SSHPort), time.Second)
}
// startVfkit starts the vfkit child process. If socketPath is not empty, vfkit
// is connected to the vmnet network via the socket instead of "nat" network.
func (d *Driver) startVfkit(socketPath string) error {
var startCmd []string
startCmd = append(startCmd,
"--memory", fmt.Sprintf("%d", d.Memory),
"--cpus", fmt.Sprintf("%d", d.CPU),
"--restful-uri", fmt.Sprintf("unix://%s", d.sockfilePath()),
"--log-level", "debug")
// On arm64 console= is required get boot messages in serial.log. On x86_64
// serial log is always empty.
var cmdline string
switch runtime.GOARCH {
case "arm64":
cmdline = "console=hvc0"
case "amd64":
cmdline = "console=ttyS0"
}
// TODO: Switch to --bootloader efi when x86_64 iso changed to EFI.
startCmd = append(startCmd,
"--bootloader", fmt.Sprintf("linux,kernel=%s,initrd=%s,cmdline=\"%s\"",
d.kernelPath(), d.initrdPath(), cmdline))
if socketPath != "" {
// The guest will be able to access other guests in the vmnet network.
startCmd = append(startCmd,
"--device", fmt.Sprintf("virtio-net,unixSocketPath=%s,mac=%s", socketPath, d.MACAddress))
} else {
// The guest will not be able to access other guests.
startCmd = append(startCmd,
"--device", fmt.Sprintf("virtio-net,nat,mac=%s", d.MACAddress))
}
startCmd = append(startCmd,
"--device", "virtio-rng")
startCmd = append(startCmd,
"--device", fmt.Sprintf("virtio-blk,path=%s", d.isoPath()))
startCmd = append(startCmd,
"--device", fmt.Sprintf("virtio-blk,path=%s", d.diskPath()))
for i := 0; i < d.ExtraDisks; i++ {
startCmd = append(startCmd,
"--device", fmt.Sprintf("virtio-blk,path=%s", common.ExtraDiskPath(d.BaseDriver, i)))
}
serialPath := d.ResolveStorePath(serialFileName)
startCmd = append(startCmd,
"--device", fmt.Sprintf("virtio-serial,logFilePath=%s", serialPath))
log.Debugf("executing: vfkit %s", strings.Join(startCmd, " "))
os.Remove(d.sockfilePath())
cmd := exec.Command("vfkit", startCmd...)
// Create vfkit in a new process group, so minikube caller can use killpg
// to terminate the entire process group without harming the vfkit process.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
logfile, err := d.openLogfile()
if err != nil {
return fmt.Errorf("failed to open vfkit logfile: %w", err)
}
defer logfile.Close()
cmd.Stderr = logfile
if err := cmd.Start(); err != nil {
return err
}
return process.WritePidfile(d.pidfilePath(), cmd.Process.Pid)
}
func (d *Driver) setupIP(mac string) error {
var err error
getIP := func() error {
d.IPAddress, err = common.GetIPAddressByMACAddress(mac)
if err != nil {
return errors.Wrap(err, "failed to get IP address")
}
return nil
}
// Implement a retry loop because IP address isn't added to dhcp leases file immediately
multiplier := 1
if detect.NestedVM() {
multiplier = 3 // will help with running in Free github action Macos VMs (takes 160+ retries on average)
}
for i := 0; i < 60*multiplier; i++ {
log.Debugf("Attempt %d", i)
err = getIP()
if err == nil {
break
}
time.Sleep(2 * time.Second)
}
if err == nil {
log.Debugf("IP: %s", d.IPAddress)
return nil
}
if !isBootpdError(err) {
return errors.Wrap(err, "IP address never found in dhcp leases file")
}
if unblockErr := firewall.UnblockBootpd(); unblockErr != nil {
klog.Errorf("failed unblocking bootpd from firewall: %v", unblockErr)
exit.Error(reason.IfBootpdFirewall, "ip not found", err)
}
out.Styled(style.Restarting, "Successfully unblocked bootpd process from firewall, retrying")
return fmt.Errorf("ip not found: %v", err)
}
func isBootpdError(err error) bool {
return strings.Contains(err.Error(), "could not find an IP address")
}
func (d *Driver) openLogfile() (*os.File, error) {
logfile := d.ResolveStorePath(logFileName)
return os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
}
func (d *Driver) stopVfkit() error {
if err := d.SetVFKitState("Stop"); err != nil {
// vfkit may be already stopped, shutting down, or not listening.
// We don't fallback to "HardStop" since it typically fails due to
// https://github.com/crc-org/vfkit/issues/277.
log.Debugf("Failed to set vfkit state to 'Stop': %s", err)
pidfile := d.pidfilePath()
pid, err := process.ReadPidfile(pidfile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
// No pidfile.
return nil
}
if err := process.Terminate(pid, "vfkit"); err != nil {
if err != os.ErrProcessDone {
return err
}
// No process, stale pidfile.
if err := os.Remove(pidfile); err != nil {
log.Debugf("failed to remove %q: %s", pidfile, err)
}
return nil
}
}
return nil
}
func (d *Driver) stopVmnetHelper() error {
if d.VmnetHelper == nil {
return nil
}
return d.VmnetHelper.Stop()
}
func (d *Driver) Stop() error {
if err := d.stopVfkit(); err != nil {
return err
}
return d.stopVmnetHelper()
}
func (d *Driver) Remove() error {
s, err := d.GetState()
if err != nil {
return errors.Wrap(err, "get state")
}
if s == state.Running {
if err := d.Kill(); err != nil {
return errors.Wrap(err, "kill")
}
}
return nil
}
func (d *Driver) Restart() error {
s, err := d.GetState()
if err != nil {
return err
}
if s == state.Running {
if err := d.Stop(); err != nil {
return err
}
}
return d.Start()
}
func (d *Driver) killVfkit() error {
if err := d.SetVFKitState("HardStop"); err != nil {
// Typically fails with EOF due to https://github.com/crc-org/vfkit/issues/277.
log.Debugf("Failed to set vfkit state to 'HardStop': %s", err)
pidfile := d.pidfilePath()
pid, err := process.ReadPidfile(pidfile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
// No pidfile.
return nil
}
if err := process.Kill(pid, "vfkit"); err != nil {
if err != os.ErrProcessDone {
return err
}
// No process, stale pidfile.
if err := os.Remove(pidfile); err != nil {
log.Debugf("failed to remove %q: %s", pidfile, err)
}
return nil
}
}
return nil
}
func (d *Driver) killVmnetHelper() error {
if d.VmnetHelper == nil {
return nil
}
return d.VmnetHelper.Kill()
}
func (d *Driver) Kill() error {
if err := d.killVfkit(); err != nil {
return err
}
return d.killVmnetHelper()
}
func (d *Driver) StartDocker() error {
return fmt.Errorf("hosts without a driver cannot start docker")
}
func (d *Driver) StopDocker() error {
return fmt.Errorf("hosts without a driver cannot stop docker")
}
func (d *Driver) GetDockerConfigDir() string {
return ""
}
func (d *Driver) Upgrade() error {
return fmt.Errorf("hosts without a driver cannot be upgraded")
}
func (d *Driver) sshKeyPath() string {
machineDir := filepath.Join(d.StorePath, "machines", d.GetMachineName())
return filepath.Join(machineDir, "id_rsa")
}
func (d *Driver) publicSSHKeyPath() string {
return d.sshKeyPath() + ".pub"
}
func (d *Driver) isoPath() string {
return d.ResolveStorePath(isoFilename)
}
func (d *Driver) kernelPath() string {
return d.ResolveStorePath("bzimage")
}
func (d *Driver) initrdPath() string {
return d.ResolveStorePath("initrd")
}
func (d *Driver) diskPath() string {
machineDir := filepath.Join(d.StorePath, "machines", d.GetMachineName())
return filepath.Join(machineDir, "disk.img")
}
func (d *Driver) sockfilePath() string {
machineDir := filepath.Join(d.StorePath, "machines", d.GetMachineName())
return filepath.Join(machineDir, sockFilename)
}
func (d *Driver) pidfilePath() string {
machineDir := filepath.Join(d.StorePath, "machines", d.GetMachineName())
return filepath.Join(machineDir, pidFileName)
}
// Make a boot2docker VM disk image.
func (d *Driver) generateDiskImage(size int) error {
log.Debugf("Creating %d MB hard disk image...", size)
magicString := "boot2docker, please format-me"
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
// magicString first so the automount script knows to format the disk
file := &tar.Header{Name: magicString, Size: int64(len(magicString))}
if err := tw.WriteHeader(file); err != nil {
return err
}
if _, err := tw.Write([]byte(magicString)); err != nil {
return err
}
// .ssh/key.pub => authorized_keys
file = &tar.Header{Name: ".ssh", Typeflag: tar.TypeDir, Mode: 0700}
if err := tw.WriteHeader(file); err != nil {
return err
}
pubKey, err := os.ReadFile(d.publicSSHKeyPath())
if err != nil {
return err
}
file = &tar.Header{Name: ".ssh/authorized_keys", Size: int64(len(pubKey)), Mode: 0644}
if err := tw.WriteHeader(file); err != nil {
return err
}
if _, err := tw.Write(pubKey); err != nil {
return err
}
file = &tar.Header{Name: ".ssh/authorized_keys2", Size: int64(len(pubKey)), Mode: 0644}
if err := tw.WriteHeader(file); err != nil {
return err
}
if _, err := tw.Write(pubKey); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
rawFile := d.diskPath()
if err := os.WriteFile(rawFile, buf.Bytes(), 0644); err != nil {
return nil
}
if err := os.Truncate(rawFile, int64(size)*int64(1024*1024)); err != nil {
return nil
}
log.Debugf("DONE writing to %s and %s", rawFile, d.diskPath())
return nil
}
func httpUnixClient(path string) http.Client {
return http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", path)
},
},
}
}
type VMState struct {
State string `json:"state"`
}
func (d *Driver) GetVFKitState() (string, error) {
httpc := httpUnixClient(d.sockfilePath())
var vmstate VMState
response, err := httpc.Get("http://_/vm/state")
if err != nil {
return "", err
}
defer response.Body.Close()
err = json.NewDecoder(response.Body).Decode(&vmstate)
if err != nil {
return "", err
}
log.Debugf("get state: %+v", vmstate)
return vmstate.State, nil
}
// SetVFKitState sets the state of the vfkit VM, (s is the state)
func (d *Driver) SetVFKitState(s string) error {
httpc := httpUnixClient(d.sockfilePath())
var vmstate VMState
vmstate.State = s
data, err := json.Marshal(&vmstate)
if err != nil {
return err
}
_, err = httpc.Post("http://_/vm/state", "application/json", bytes.NewReader(data))
if err != nil {
return err
}
log.Infof("Set vfkit state: %+v", vmstate)
return nil
}
func WaitForTCPWithDelay(addr string, duration time.Duration) error {
for {
conn, err := net.Dial("tcp", addr)
if err != nil {
continue
}
defer conn.Close()
if _, err := conn.Read(make([]byte, 1)); err != nil && err != io.EOF {
time.Sleep(duration)
continue
}
break
}
return nil
}