pull/22503/merge
Bob Sira 2026-03-17 00:14:51 +00:00 committed by GitHub
commit 880f6e3b30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1509 additions and 65 deletions

View File

@ -17,6 +17,9 @@ limitations under the License.
package cmd
import (
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -37,6 +40,14 @@ var (
cpNode bool
workerNode bool
deleteNodeOnFailure bool
osType string
osTypeLong = "This flag should only be used when adding a windows node to a cluster.\n\n" +
"Specify the OS of the node to add in the format 'os=OS_TYPE,version=VERSION'.\n" +
"This means that the node to be added will be a Windows node and the version of Windows OS to use for that node is Windows Server 2022.\n" +
"Example: $ minikube node add --os='os=windows,version=2022'\n" +
"Valid options for OS_TYPE are: linux, windows. If not specified, the default value is linux.\n" +
"You do not need to specify the --os flag if you are adding a linux node."
)
var nodeAddCmd = &cobra.Command{
@ -44,6 +55,20 @@ var nodeAddCmd = &cobra.Command{
Short: "Adds a node to the given cluster.",
Long: "Adds a node to the given cluster config, and starts it.",
Run: func(cmd *cobra.Command, _ []string) {
osType, windowsVersion, err := parseOSFlag(osType)
if err != nil {
exit.Message(reason.Usage, "{{.err}}", out.V{"err": err})
}
if err := validateOSandVersion(osType, windowsVersion); err != nil {
exit.Message(reason.Usage, "{{.err}}", out.V{"err": err})
}
if osType == "windows" && cpNode {
exit.Message(reason.Usage, "Windows node cannot be used as control-plane nodes.")
}
options := flags.CommandOptions()
co := mustload.Healthy(ClusterFlagValue(), options)
@ -112,6 +137,42 @@ func init() {
nodeAddCmd.Flags().BoolVar(&cpNode, "control-plane", false, "If set, added node will become a control-plane. Defaults to false. Currently only supported for existing HA (multi-control plane) clusters.")
nodeAddCmd.Flags().BoolVar(&workerNode, "worker", true, "If set, added node will be available as worker. Defaults to true.")
nodeAddCmd.Flags().BoolVar(&deleteNodeOnFailure, "delete-on-failure", false, "If set, delete the current cluster if start fails and try again. Defaults to false.")
nodeAddCmd.Flags().StringVar(&osType, "os", "linux", osTypeLong)
nodeCmd.AddCommand(nodeAddCmd)
}
// parseOSFlag parses the --os flag value , 'os=OS_TYPE,version=VERSION', and returns the os type and version
// For example, 'os=windows,version=2022' The output will be os: 'windows' and version: '2022' respectively
func parseOSFlag(osFlagValue string) (string, string, error) {
// Remove all spaces from the input string
osFlagValue = strings.ReplaceAll(osFlagValue, " ", "")
parts := strings.Split(osFlagValue, ",")
osInfo := map[string]string{
"os": "linux", // default value
"version": "",
}
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) != 2 {
return "", "", errors.Errorf("Invalid format for --os flag: %s", osFlagValue)
}
osInfo[kv[0]] = kv[1]
}
// if os is specified to linux, set the version to empty string as it is not required
if osInfo["os"] == "linux" {
if osInfo["version"] != "" {
out.WarningT("Ignoring version flag for linux os. You do not need to specify the version for linux os.")
}
osInfo["version"] = ""
}
// if os is specified to windows and version is not specified, set the default version to 2022(Windows Server 2022)
if osInfo["os"] == "windows" && osInfo["version"] == "" {
osInfo["version"] = "2022"
}
return osInfo["os"], osInfo["version"], nil
}

View File

@ -0,0 +1,134 @@
/*
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 cmd
import (
"testing"
"github.com/pkg/errors"
)
func TestValidateOS(t *testing.T) {
tests := []struct {
osType string
errorMsg string
}{
{"linux", ""},
{"windows", ""},
{"foo", "Invalid OS: foo. Valid OS are: linux, windows"},
}
for _, test := range tests {
t.Run(test.osType, func(t *testing.T) {
got := validateOS(test.osType)
gotError := ""
if got != nil {
gotError = got.Error()
}
if gotError != test.errorMsg {
t.Errorf("validateOS(osType=%v): got %v, expected %v", test.osType, got, test.errorMsg)
}
})
}
}
// TestParseOSFlag is the main test function for parseOSFlag
func TestParseOSFlag(t *testing.T) {
tests := []struct {
name string
input string
expectedOS string
expectedVer string
expectedErr error
}{
{
name: "Valid input with all fields",
input: "os=windows,version=2019",
expectedOS: "windows",
expectedVer: "2019",
expectedErr: nil,
},
{
name: "Valid input with default version for windows",
input: "os=windows",
expectedOS: "windows",
expectedVer: "2022",
expectedErr: nil,
},
{
name: "Valid input with linux and no version",
input: "os=linux",
expectedOS: "linux",
expectedVer: "",
expectedErr: nil,
},
{
name: "Invalid input with missing version",
input: "os=linux,version=",
expectedOS: "linux",
expectedVer: "",
expectedErr: nil,
},
{
name: "Invalid input with extra comma",
input: "os=linux,version=,",
expectedOS: "",
expectedVer: "",
expectedErr: errors.New("Invalid format for --os flag: os=linux,version=,"),
},
{
name: "Invalid input with no key-value pair",
input: "linux,version=2022",
expectedOS: "",
expectedVer: "",
expectedErr: errors.New("Invalid format for --os flag: linux,version=2022"),
},
{
name: "Valid input with extra spaces",
input: "os=linux , version=latest",
expectedOS: "linux",
expectedVer: "",
expectedErr: nil,
},
{
name: "Valid input with capital letters in keys",
input: "OS=linux,Version=2022",
expectedOS: "linux",
expectedVer: "",
expectedErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotOS, gotVer, err := parseOSFlag(tt.input)
if tt.expectedErr != nil && (err == nil || err.Error() != tt.expectedErr.Error()) {
t.Errorf("Expected error %v, got %v", tt.expectedErr, err)
} else if tt.expectedErr == nil && err != nil {
t.Errorf("Expected no error, but got %v", err)
}
if gotOS != tt.expectedOS {
t.Errorf("Expected OS %s, got %s", tt.expectedOS, gotOS)
}
if gotVer != tt.expectedVer {
t.Errorf("Expected version %s, got %s", tt.expectedVer, gotVer)
}
})
}
}

View File

@ -46,6 +46,7 @@ import (
gopshost "github.com/shirou/gopsutil/v4/host"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/exp/maps"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"k8s.io/klog/v2"
@ -193,6 +194,14 @@ func runStart(cmd *cobra.Command, _ []string) {
out.WarningT("Profile name '{{.name}}' is not valid", out.V{"name": ClusterFlagValue()})
exit.Message(reason.Usage, "Only alphanumeric and dashes '-' are permitted. Minimum 2 characters, starting with alphanumeric.")
}
// change the driver to hyperv, cni to flannel and container runtime to containerd if we have --node-os=windows
if cmd.Flags().Changed(nodeOS) {
viper.Set("driver", driver.HyperV)
viper.Set("cni", "flannel")
viper.Set(containerRuntime, constants.Containerd)
}
existing, err := config.Load(ClusterFlagValue())
if err != nil && !config.IsNotExist(err) {
kind := reason.HostConfigLoad
@ -495,6 +504,13 @@ func startWithDriver(cmd *cobra.Command, starter node.Starter, existing *config.
// target total and number of control-plane nodes
numCPNodes := 1
numNodes := viper.GetInt(nodes)
// if we have -node-os flag set, then nodes flag will be set to 2
// it means one of the nodes is a control-plane node and the other is a windows worker node
// so we need to reduce the numNodes by 1
if cmd.Flags().Changed(nodeOS) {
numNodes--
}
if existing != nil {
numCPNodes = 0
for _, n := range existing.Nodes {
@ -520,6 +536,11 @@ func startWithDriver(cmd *cobra.Command, starter node.Starter, existing *config.
KubernetesVersion: starter.Cfg.KubernetesConfig.KubernetesVersion,
ContainerRuntime: starter.Cfg.KubernetesConfig.ContainerRuntime,
Worker: true,
Guest: config.Guest{
Name: "linux",
Version: "latest",
URL: "",
},
}
if i < numCPNodes { // starter node is also counted as (primary) cp node
n.ControlPlane = true
@ -527,8 +548,34 @@ func startWithDriver(cmd *cobra.Command, starter node.Starter, existing *config.
}
out.Ln("") // extra newline for clarity on the command line
// 1st call
if err := node.Add(starter.Cfg, n, viper.GetBool(deleteOnFailure), options); err != nil {
return nil, fmt.Errorf("adding node: %w", err)
return nil, fmt.Errorf("adding linux node: %w", err)
}
}
// we currently trigger the windows node start if the user has set the --windows-node-version or --node-os flag
// we might need to get rid of --windows-node-version in the future and just use --node-os flag
// start windows node. trigger windows node start if windows node version or node node os is set at the time of minikube start
if cmd.Flags().Changed(windowsNodeVersion) || cmd.Flags().Changed(nodeOS) {
// TODO: if windows node version is set to windows server 2022 then the windows node name should be minikube-ws2022
nodeName := node.Name(numNodes + 1)
n := config.Node{
Name: nodeName,
Port: starter.Cfg.APIServerPort,
KubernetesVersion: starter.Cfg.KubernetesConfig.KubernetesVersion,
ContainerRuntime: starter.Cfg.KubernetesConfig.ContainerRuntime,
Worker: true,
Guest: config.Guest{
Name: "windows",
Version: viper.GetString(windowsNodeVersion),
URL: viper.GetString(windowsVhdURL),
},
}
out.Ln("") // extra newline for clarity on the command line
if err := node.Add(starter.Cfg, n, viper.GetBool(deleteOnFailure), options); err != nil {
return nil, fmt.Errorf("adding windows node: %w", err)
}
}
@ -1339,6 +1386,37 @@ func validateFlags(cmd *cobra.Command, drvName string) { //nolint:gocyclo
validateCNI(cmd, viper.GetString(containerRuntime))
}
if cmd.Flags().Changed(windowsNodeVersion) {
if err := validateWindowsOSVersion(viper.GetString(windowsNodeVersion)); err != nil {
exit.Message(reason.Usage, "{{.err}}", out.V{"err": err})
}
}
if cmd.Flags().Changed(nodeOS) {
if err := validMultiNodeOS(viper.GetString(nodeOS)); err != nil {
exit.Message(reason.Usage, "{{.err}}", out.V{"err": err})
}
if viper.GetInt(nodes) != 2 {
exit.Message(reason.Usage, "The --nodes flag must be set to 2 when using --node-os")
}
}
if cmd.Flags().Changed(windowsVhdURL) {
if viper.GetString(windowsVhdURL) == "" {
// set a default URL if the user has not specified one
viper.Set(windowsVhdURL, constants.DefaultWindowsVhdURL)
exit.Message(reason.Usage, "The --windows-vhd-url flag must be set to a valid URL")
}
// add validation logic for the windows vhd URL
url := viper.GetString(windowsVhdURL)
if !strings.HasSuffix(url, ".vhd") && !strings.HasSuffix(url, ".vhdx") {
exit.Message(reason.Usage, "The --windows-vhd-url flag must point to a valid VHD or VHDX file")
}
} ////
if cmd.Flags().Changed(staticIP) {
if err := validateStaticIP(viper.GetString(staticIP), drvName, viper.GetString(subnet)); err != nil {
exit.Message(reason.Usage, "{{.err}}", out.V{"err": err})
@ -1457,6 +1535,63 @@ func validateDiskSize(diskSize string) error {
return nil
}
// validateWindowsOSVersion validates the supplied window server os version
func validateWindowsOSVersion(osVersion string) error {
validOptions := node.ValidWindowsOSVersions()
if validOptions[osVersion] {
return nil
}
return fmt.Errorf("Invalid Windows Server OS Version: %s. Valid OS version are: %s", osVersion, maps.Keys(validOptions))
}
// validateOS validates the supplied OS
func validateOS(os string) error {
validOptions := node.ValidOS()
for _, validOS := range validOptions {
if os == validOS {
return nil
}
}
return fmt.Errorf("Invalid OS: %s. Valid OS are: %s", os, strings.Join(validOptions, ", "))
}
// validateOSandVersion validates the supplied OS and version
func validateOSandVersion(os, version string) error {
if err := validateOS(os); err != nil {
return err
}
if version != "" {
if err := validateWindowsOSVersion(version); err != nil {
return err
}
}
return nil
}
// validateMultiNodeOS validates the supplied OS for multiple nodes
func validMultiNodeOS(osString string) error {
if !strings.HasPrefix(osString, "[") || !strings.HasSuffix(osString, "]") {
return fmt.Errorf("invalid OS string format: must be enclosed in [ ]")
}
osString = strings.Trim(osString, "[]")
osString = strings.ReplaceAll(osString, " ", "")
osValues := strings.Split(osString, ",")
if len(osValues) != 2 || osValues[0] != "linux" || osValues[1] != "windows" {
return fmt.Errorf("invalid OS string format: must be [linux,windows]")
}
return nil
}
// validateRuntime validates the supplied runtime
func validateRuntime(rtime string) error {
validOptions := cruntime.ValidRuntimes()

View File

@ -118,6 +118,7 @@ const (
ha = "ha"
nodes = "nodes"
preload = "preload"
preloadWindowsIso = "preload-windows-iso"
deleteOnFailure = "delete-on-failure"
forceSystemd = "force-systemd"
kicBaseImage = "base-image"
@ -145,6 +146,9 @@ const (
staticIP = "static-ip"
gpus = "gpus"
autoPauseInterval = "auto-pause-interval"
windowsNodeVersion = "windows-node-version"
nodeOS = "node-os"
windowsVhdURL = "windows-vhd-url"
preloadSrc = "preload-source"
rosetta = "rosetta"
)
@ -190,13 +194,14 @@ func initMinikubeFlags() {
startCmd.Flags().Bool(enableDefaultCNI, false, "DEPRECATED: Replaced by --cni=bridge")
startCmd.Flags().String(cniFlag, "", "CNI plug-in to use. Valid options: auto, bridge, calico, cilium, flannel, kindnet, or path to a CNI manifest (default: auto)")
startCmd.Flags().StringSlice(waitComponents, kverify.DefaultWaitList, fmt.Sprintf("comma separated list of Kubernetes components to verify and wait for after starting a cluster. defaults to %q, available options: %q . other acceptable values are 'all' or 'none', 'true' and 'false'", strings.Join(kverify.DefaultWaitList, ","), strings.Join(kverify.AllComponentsList, ",")))
startCmd.Flags().Duration(waitTimeout, 6*time.Minute, "max time to wait per Kubernetes or host to be healthy.")
startCmd.Flags().Duration(waitTimeout, 12*time.Minute, "max time to wait per Kubernetes or host to be healthy.")
startCmd.Flags().Bool(nativeSSH, true, "Use native Golang SSH client (default true). Set to 'false' to use the command line 'ssh' command when accessing the docker machine. Useful for the machine drivers when they will not start with 'Waiting for SSH'.")
startCmd.Flags().Bool(autoUpdate, true, "If set, automatically updates drivers to the latest version. Defaults to true.")
startCmd.Flags().Bool(installAddons, true, "If set, install addons. Defaults to true.")
startCmd.Flags().Bool(ha, false, "Create Highly Available Multi-Control Plane Cluster with a minimum of three control-plane nodes that will also be marked for work.")
startCmd.Flags().IntP(nodes, "n", 1, "The total number of nodes to spin up. Defaults to 1.")
startCmd.Flags().Bool(preload, true, "If set, download tarball of preloaded images if available to improve start time. Defaults to true.")
startCmd.Flags().Bool(preloadWindowsIso, false, "If set, download the Windows ISO to improve start time of setting up a windows node. Defaults to false.")
startCmd.Flags().Bool(noKubernetes, false, "If set, minikube VM/container will start without starting or configuring Kubernetes. (only works on new clusters)")
startCmd.Flags().Bool(deleteOnFailure, false, "If set, delete the current cluster if start fails and try again. Defaults to false.")
startCmd.Flags().Bool(forceSystemd, false, "If set, force the container runtime to use systemd as cgroup manager. Defaults to false.")
@ -212,7 +217,12 @@ func initMinikubeFlags() {
startCmd.Flags().String(staticIP, "", "Set a static IP for the minikube cluster, the IP must be: private, IPv4, and the last octet must be between 2 and 254, for example 192.168.200.200 (Docker and Podman drivers only)")
startCmd.Flags().StringP(gpus, "g", "", "Allow pods to use your GPUs. Options include: [all,nvidia,amd] (Docker driver with Docker container-runtime only)")
startCmd.Flags().Duration(autoPauseInterval, time.Minute*1, "Duration of inactivity before the minikube VM is paused (default 1m0s)")
startCmd.Flags().String(windowsNodeVersion, constants.DefaultWindowsNodeVersion, "The version of Windows to use for the windows node on a multi-node cluster (e.g., 2025). Currently support Windows Server 2025")
startCmd.Flags().String(nodeOS, "node-os", "The OS to use for the node. Currently support 'linux, windows'. If not set, it will be set to the same as the control plane node.")
startCmd.Flags().String(windowsVhdURL, constants.DefaultWindowsVhdURL, "The VHD URL to use for the windows node on a multi-node cluster. If not set, it will be set to the default Windows Server 2025 VHD URL.")
startCmd.Flags().String(preloadSrc, "auto", "Which source to download the preload from (valid options: gcs, github, auto). Defaults to auto (try both).")
}
// initKubernetesFlags inits the commandline flags for Kubernetes related options
@ -655,6 +665,7 @@ func generateNewConfigFromFlags(cmd *cobra.Command, k8sVersion string, rtime str
SocketVMnetClientPath: detect.SocketVMNetClientPath(),
SocketVMnetPath: detect.SocketVMNetPath(),
StaticIP: viper.GetString(staticIP),
WindowsNodeVersion: viper.GetString(windowsNodeVersion),
KubernetesConfig: config.KubernetesConfig{
KubernetesVersion: k8sVersion,
ClusterName: ClusterFlagValue(),
@ -907,6 +918,7 @@ func updateExistingConfigFromFlags(cmd *cobra.Command, existing *config.ClusterC
updateStringFromFlag(cmd, &cc.SocketVMnetClientPath, socketVMnetClientPath)
updateStringFromFlag(cmd, &cc.SocketVMnetPath, socketVMnetPath)
updateDurationFromFlag(cmd, &cc.AutoPauseInterval, autoPauseInterval)
updateStringFromFlag(cmd, &cc.WindowsNodeVersion, windowsNodeVersion)
updateBoolFromFlag(cmd, &cc.Rosetta, rosetta)
if cmd.Flags().Changed(kubernetesVersion) {

View File

@ -477,6 +477,79 @@ func TestValidateRuntime(t *testing.T) {
}
}
func TestValidateWindowsOSVersion(t *testing.T) {
var tests = []struct {
osVersion string
errorMsg string
}{
{
osVersion: "2025",
errorMsg: "",
},
{
osVersion: "2023",
errorMsg: "Invalid Windows Server OS Version: 2023. Valid OS version are: [2025]",
},
}
for _, test := range tests {
t.Run(test.osVersion, func(t *testing.T) {
got := validateWindowsOSVersion(test.osVersion)
gotError := ""
if got != nil {
gotError = got.Error()
}
if gotError != test.errorMsg {
t.Errorf("ValidateWindowsOSVersion(osVersion=%v): got %v, expected %v", test.osVersion, got, test.errorMsg)
}
})
}
}
func TestValidMultiNodeOS(t *testing.T) {
var tests = []struct {
osString string
errorMsg string
}{
{
osString: "[linux,windows]",
errorMsg: "",
},
{
osString: "[linux, windows]",
errorMsg: "",
},
{
osString: "[windows,linux]",
errorMsg: "invalid OS string format: must be [linux,windows]",
},
{
osString: "[linux]",
errorMsg: "invalid OS string format: must be [linux,windows]",
},
{
osString: "[linux,windows,mac]",
errorMsg: "invalid OS string format: must be [linux,windows]",
},
{
osString: "linux,windows",
errorMsg: "invalid OS string format: must be enclosed in [ ]",
},
}
for _, test := range tests {
t.Run(test.osString, func(t *testing.T) {
got := validMultiNodeOS(test.osString)
gotError := ""
if got != nil {
gotError = got.Error()
}
if gotError != test.errorMsg {
t.Errorf("validMultiNodeOS(osString=%v): got %v, expected %v", test.osString, gotError, test.errorMsg)
}
})
}
}
func TestIsTwoDigitSemver(t *testing.T) {
var tcs = []struct {
desc string

2
go.mod
View File

@ -47,6 +47,7 @@ require (
github.com/otiai10/copy v1.14.1
github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/errors v0.9.1
github.com/pkg/profile v1.7.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/shirou/gopsutil/v4 v4.26.1
@ -197,7 +198,6 @@ require (
github.com/otiai10/mint v1.6.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/xattr v0.4.9 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect

View File

@ -207,6 +207,7 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect

View File

@ -18,15 +18,17 @@ package hyperv
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"errors"
"k8s.io/minikube/pkg/libmachine/drivers"
"k8s.io/minikube/pkg/libmachine/log"
"k8s.io/minikube/pkg/libmachine/mcnflag"
@ -38,6 +40,7 @@ import (
type Driver struct {
*drivers.BaseDriver
Boot2DockerURL string
WindowsVHDUrl string
VSwitch string
DiskSize int
MemSize int
@ -45,6 +48,7 @@ type Driver struct {
MacAddr string
VLanID int
DisableDynamicMemory bool
OS string
}
const (
@ -54,6 +58,7 @@ const (
defaultVLanID = 0
defaultDisableDynamicMemory = false
defaultSwitchID = "c08cb7b8-9b3c-408e-8e30-5e16a3aeb444"
defaultServerImageFilename = "hybrid-minikube-windows-server.vhdx"
)
// NewDriver creates a new Hyper-v driver with default settings.
@ -62,6 +67,7 @@ func NewDriver(hostName, storePath string) *Driver {
DiskSize: defaultDiskSize,
MemSize: defaultMemory,
CPU: defaultCPU,
WindowsVHDUrl: mcnutils.ConfigGuest.GetVHDUrl(),
DisableDynamicMemory: defaultDisableDynamicMemory,
BaseDriver: &drivers.BaseDriver{
MachineName: hostName,
@ -205,17 +211,82 @@ func (d *Driver) PreCreateCheck() error {
return err
}
// Downloading boot2docker to cache should be done here to make sure
// Downloading boot2docker/windows-server to cache should be done here to make sure
// that a download failure will not leave a machine half created.
b2dutils := mcnutils.NewB2dUtils(d.StorePath)
err = b2dutils.UpdateISOCache(d.Boot2DockerURL)
if mcnutils.ConfigGuest.GetGuestOS() != "windows" {
err = b2dutils.UpdateISOCache(d.Boot2DockerURL)
} else {
err = b2dutils.UpdateVHDCache(d.WindowsVHDUrl)
}
if mcnutils.ConfigGuest.GetGuestOS() == "windows" {
vhdxPath := filepath.Join(b2dutils.GetImgCachePath(), defaultServerImageFilename)
mounted := false
defer func() {
if mounted {
if err := cmd("Hyper-V\\Dismount-VHD", "-Path", quote(vhdxPath)); err != nil {
log.Errorf("failed to dismount VHDX %s: %v", vhdxPath, err)
}
}
}()
if err := cmd("Hyper-V\\Mount-VHD", "-Path", quote(vhdxPath), "-ReadOnly"); err != nil {
return fmt.Errorf("failed to mount VHDX: %w", err)
}
mounted = true
diskNumOut, err := cmdOut("-Command", fmt.Sprintf(
"$d=(Get-VHD -Path %s | Get-Disk).Number; if($d -ne $null){$d}else{''}",
quote(vhdxPath),
))
if err != nil {
return fmt.Errorf("failed to determine disk number for VHDX: %w", err)
}
diskNumber := strings.TrimSpace(diskNumOut)
if diskNumber == "" {
return fmt.Errorf("could not determine disk number for mounted VHDX")
}
avail, err := cmdOut("-Command",
"$used=(Get-PSDrive -PSProvider FileSystem).Name; "+
"$letters='DEFGHIJKLMNOPQRSTUVWXYZ'.ToCharArray(); "+
"$free=$letters | Where-Object { $used -notcontains $_ }; "+
"if ($free) { $free[0] } else { '' }",
)
if err != nil {
return fmt.Errorf("failed to determine available drive letter: %w", err)
}
freeLetter := strings.TrimSpace(avail)
if freeLetter == "" {
return fmt.Errorf("no available drive letters to assign to VHDX")
}
if err := cmd("Set-Partition",
"-DiskNumber", diskNumber,
"-PartitionNumber", "4",
"-NewDriveLetter", freeLetter,
); err != nil {
return fmt.Errorf("failed to set partition on VHDX: %w", err)
}
}
return err
}
func (d *Driver) Create() error {
b2dutils := mcnutils.NewB2dUtils(d.StorePath)
if err := b2dutils.CopyIsoToMachineDir(d.Boot2DockerURL, d.MachineName); err != nil {
return err
if mcnutils.ConfigGuest.GetGuestOS() == "windows" {
d.SSHUser = "Administrator"
if err := b2dutils.CopyWindowsVHDToMachineDir(d.WindowsVHDUrl, d.MachineName); err != nil {
return err
}
} else {
if err := b2dutils.CopyIsoToMachineDir(d.Boot2DockerURL, d.MachineName); err != nil {
return err
}
}
log.Infof("Creating SSH key...")
@ -233,15 +304,33 @@ func (d *Driver) Create() error {
}
log.Infof("Using switch %q", d.VSwitch)
diskImage, err := d.generateDiskImage()
if mcnutils.ConfigGuest.GetGuestOS() == "windows" {
log.Infof("Adding SSH key to the VHDX...")
if err := writeSSHKeyToVHDX(d.ResolveStorePath(defaultServerImageFilename), d.publicSSHKeyPath()); err != nil {
log.Errorf("Error creating disk image: %s", err)
return err
}
}
var diskImage string
var err error
if mcnutils.ConfigGuest.GetGuestOS() != "windows" {
diskImage, err = d.generateDiskImage()
}
if err != nil {
return err
}
vmGeneration := "1"
if mcnutils.ConfigGuest.GetGuestOS() == "windows" {
vmGeneration = "2"
}
if err := cmd("Hyper-V\\New-VM",
d.MachineName,
"-Path", fmt.Sprintf("'%s'", d.ResolveStorePath(".")),
"-SwitchName", quote(d.VSwitch),
"-Generation", quote(vmGeneration),
"-MemoryStartupBytes", toMb(d.MemSize)); err != nil {
return err
}
@ -256,7 +345,8 @@ func (d *Driver) Create() error {
if d.CPU > 1 {
if err := cmd("Hyper-V\\Set-VMProcessor",
d.MachineName,
"-Count", fmt.Sprintf("%d", d.CPU)); err != nil {
"-Count", fmt.Sprintf("%d", d.CPU),
"-ExposeVirtualizationExtensions", "$true"); err != nil {
return err
}
}
@ -278,16 +368,28 @@ func (d *Driver) Create() error {
}
}
if err := cmd("Hyper-V\\Set-VMDvdDrive",
"-VMName", d.MachineName,
"-Path", quote(d.ResolveStorePath("boot2docker.iso"))); err != nil {
return err
if mcnutils.ConfigGuest.GetGuestOS() != "windows" {
log.Infof("Attaching ISO and disk...")
if err := cmd("Hyper-V\\Set-VMDvdDrive",
"-VMName", d.MachineName,
"-Path", quote(d.ResolveStorePath("boot2docker.iso"))); err != nil {
return err
}
}
if err := cmd("Hyper-V\\Add-VMHardDiskDrive",
"-VMName", d.MachineName,
"-Path", quote(diskImage)); err != nil {
return err
if mcnutils.ConfigGuest.GetGuestOS() == "windows" {
if err := cmd("Hyper-V\\Add-VMHardDiskDrive",
"-VMName", d.MachineName,
"-Path", quote(d.ResolveStorePath("hybrid-minikube-windows-server.vhdx")),
"-ControllerType", "SCSI"); err != nil {
return err
}
} else {
if err := cmd("Hyper-V\\Add-VMHardDiskDrive",
"-VMName", d.MachineName,
"-Path", quote(diskImage)); err != nil {
return err
}
}
log.Infof("Starting VM...")
@ -529,3 +631,58 @@ func (d *Driver) generateDiskImage() (string, error) {
return diskImage, nil
}
func writeSSHKeyToVHDX(vhdxPath, publicSSHKeyPath string) (retErr error) {
output, err := cmdOut(
"-Command",
"(Get-DiskImage -ImagePath", quote(vhdxPath), "| Mount-DiskImage -PassThru) | Out-Null;",
"$diskNumber = (Get-DiskImage -ImagePath", quote(vhdxPath), "| Get-Disk).Number;",
"Set-Disk -Number $diskNumber -IsReadOnly $false;",
"(Get-Disk -Number $diskNumber | Get-Partition | Get-Volume).DriveLetter",
)
if err != nil {
return fmt.Errorf("failed to mount VHDX and retrieve mount directory: %w", err)
}
regex := regexp.MustCompile(`\s+|\r|\n`)
driveLetter := regex.ReplaceAllString(output, "")
if driveLetter == "" {
log.Debugf("No drive letter assigned to VHDX")
return errors.New("no drive letter assigned to VHDX")
}
mountDir := strings.TrimSpace(driveLetter) + ":" + string(os.PathSeparator)
defer func() {
if unmountErr := cmd("Dismount-DiskImage", "-ImagePath", quote(vhdxPath)); unmountErr != nil {
retErr = errors.Join(retErr, fmt.Errorf("failed to unmount VHDX: %w", unmountErr))
}
}()
sshDir := filepath.Join(mountDir, "ProgramData", "ssh")
adminAuthKeys := filepath.Join(sshDir, "administrators_authorized_keys")
pubKey, err := os.ReadFile(publicSSHKeyPath)
if err != nil {
return fmt.Errorf("failed to read public SSH key from %s: %w", publicSSHKeyPath, err)
}
if _, err := os.Stat(mountDir); os.IsNotExist(err) {
return fmt.Errorf("mount point %s does not exist", mountDir)
}
if err := os.MkdirAll(sshDir, 0755); err != nil {
return fmt.Errorf("failed to create SSH directory: %w", err)
}
if err := ioutil.WriteFile(adminAuthKeys, pubKey, 0644); err != nil {
return fmt.Errorf("failed to write public key: %w", err)
}
if err := cmd("icacls.exe", quote(adminAuthKeys), "/inheritance:r", "/grant", "Administrators:F", "/grant", "SYSTEM:F"); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", adminAuthKeys, err)
}
return nil
}

View File

@ -60,6 +60,7 @@ type Host struct {
HostOptions *Options
Name string
RawDriver []byte `json:"-"`
Guest Guest
}
type Options struct {
@ -71,6 +72,12 @@ type Options struct {
AuthOptions *auth.Options
}
type Guest struct {
Name string
Version string
URL string
}
type Metadata struct {
ConfigVersion int
DriverName string

View File

@ -60,7 +60,8 @@ import (
type API interface {
io.Closer
NewHost(driverName string, rawDriver []byte) (*host.Host, error)
NewHost(driverName string, guest host.Guest, rawDriver []byte) (*host.Host, error)
DefineGuest(h *host.Host)
Create(h *host.Host) error
persist.Store
GetMachinesDir() string
@ -85,7 +86,7 @@ func NewClient(storePath, certsDir string) *Client {
}
}
func (api *Client) NewHost(driverName string, rawDriver []byte) (*host.Host, error) {
func (api *Client) NewHost(driverName string, guest host.Guest, rawDriver []byte) (*host.Host, error) {
driver, err := api.clientDriverFactory.NewRPCClientDriver(driverName, rawDriver)
if err != nil {
return nil, err
@ -96,6 +97,7 @@ func (api *Client) NewHost(driverName string, rawDriver []byte) (*host.Host, err
Name: driver.GetMachineName(),
Driver: driver,
DriverName: driver.DriverName(),
Guest: guest,
HostOptions: &host.Options{
AuthOptions: &auth.Options{
CertDir: api.certsDir,
@ -145,6 +147,10 @@ func (api *Client) Load(name string) (*host.Host, error) {
return h, nil
}
func (api *Client) DefineGuest(h *host.Host) {
mcnutils.SetGuestUtil(h.Guest.Name, h.Guest.URL)
}
// Create is the wrapper method which covers all of the boilerplate around
// actually creating, provisioning, and persisting an instance in the store.
func (api *Client) Create(h *host.Host) error {

View File

@ -36,7 +36,7 @@ func (api *FakeAPI) Close() error {
return nil
}
func (api *FakeAPI) NewHost(driverName string, rawDriver []byte) (*host.Host, error) {
func (api *FakeAPI) NewHost(driverName string, guestOS string, rawDriver []byte) (*host.Host, error) {
return nil, nil
}

View File

@ -37,11 +37,12 @@ import (
)
const (
defaultURL = "https://api.github.com/repos/boot2docker/boot2docker/releases"
defaultISOFilename = "boot2docker.iso"
defaultVolumeIDOffset = int64(0x8028)
versionPrefix = "-v"
defaultVolumeIDLength = 32
defaultURL = "https://api.github.com/repos/boot2docker/boot2docker/releases"
defaultISOFilename = "boot2docker.iso"
defaultServerImageFilename = "hybrid-minikube-windows-server.vhdx"
defaultVolumeIDOffset = int64(0x8028)
versionPrefix = "-v"
defaultVolumeIDLength = 32
)
var (
@ -279,6 +280,10 @@ type iso interface {
path() string
// exists reports whether the ISO exists.
exists() bool
// pathVHD returns the path of the VHD.
pathVHD() string
// hasVHD returns whether the server VHD exists.
hasVHD() bool
// version returns version information of the ISO.
version() (string, error)
}
@ -287,6 +292,8 @@ type iso interface {
type b2dISO struct {
// path of Boot2Docker ISO
commonIsoPath string
// path of Windows Server VHD
commonVHDPath string
// offset and length of ISO volume ID
// cf. http://serverfault.com/questions/361474/is-there-a-way-to-change-a-iso-files-volume-id-from-the-command-line
@ -310,6 +317,22 @@ func (b *b2dISO) exists() bool {
return !os.IsNotExist(err)
}
func (b *b2dISO) pathVHD() string {
if b == nil {
return ""
}
return b.commonVHDPath
}
func (b *b2dISO) hasVHD() bool {
if b == nil {
return false
}
_, err := os.Stat(b.commonVHDPath)
return !os.IsNotExist(err)
}
// version scans the volume ID in b and returns its version tag.
func (b *b2dISO) version() (string, error) {
if b == nil {
@ -366,6 +389,7 @@ func NewB2dUtils(storePath string) *B2dUtils {
releaseGetter: &b2dReleaseGetter{isoFilename: defaultISOFilename},
iso: &b2dISO{
commonIsoPath: filepath.Join(imgCachePath, defaultISOFilename),
commonVHDPath: filepath.Join(imgCachePath, defaultServerImageFilename),
volumeIDOffset: defaultVolumeIDOffset,
volumeIDLength: defaultVolumeIDLength,
},
@ -374,12 +398,22 @@ func NewB2dUtils(storePath string) *B2dUtils {
}
}
func (b *B2dUtils) GetImgCachePath() string {
return b.imgCachePath
}
// DownloadISO downloads boot2docker ISO image for the given tag and save it at dest.
func (b *B2dUtils) DownloadISO(dir, file, isoURL string) error {
log.Infof("Downloading %s from %s...", b.path(), isoURL)
return b.download(dir, file, isoURL)
}
// DownloadVHD downloads the Windows Server VHD image and saves it at dest.
func (b *B2dUtils) DownloadVHD(dir, file, vhdURL string) error {
log.Infof("Downloading %s from %s...", b.pathVHD(), vhdURL)
return b.download(dir, file, vhdURL)
}
type ReaderWithProgress struct {
io.ReadCloser
out io.Writer
@ -429,6 +463,31 @@ func (b *B2dUtils) DownloadISOFromURL(latestReleaseURL string) error {
return b.DownloadISO(b.imgCachePath, b.filename(), latestReleaseURL)
}
func (b *B2dUtils) UpdateVHDCache(defaultVHDUrl string) error {
// recreate the cache dir if it has been manually deleted
// this will already be taken care of by the UpdateISOCache method for linux ISO
exists := b.hasVHD()
if !exists {
log.Info("No default Windows Server VHD found locally, downloading the latest release...")
filePath := filepath.Join(b.imgCachePath, defaultServerImageFilename)
fmt.Printf("\n")
fmt.Printf(" * Downloading and caching Windows Server VHD image...\n")
fmt.Printf(" * This may take a while...\n")
err := DownloadVHDX(defaultVHDUrl, filePath, 16, 1) // Download using 16 parts
if err != nil {
return fmt.Errorf("Error: %v", err)
}
log.Info("Windows Server VHD downloaded successfully")
}
return nil
}
func (b *B2dUtils) UpdateISOCache(isoURL string) error {
// recreate the cache dir if it has been manually deleted
if _, err := os.Stat(b.imgCachePath); os.IsNotExist(err) {
@ -487,6 +546,24 @@ func (b *B2dUtils) CopyIsoToMachineDir(isoURL, machineName string) error {
return b.DownloadISO(machineDir, b.filename(), downloadURL)
}
func (b *B2dUtils) CopyWindowsVHDToMachineDir(VHDUrl, machineName string) error {
if err := b.UpdateVHDCache(VHDUrl); err != nil {
return err
}
machineDir := filepath.Join(b.storePath, "machines", machineName)
windowsMachineVHDPath := filepath.Join(machineDir, defaultServerImageFilename)
// cached location of the windows iso
windowsVHDPath := filepath.Join(b.imgCachePath, defaultServerImageFilename)
log.Infof("Copying %s to %s...", windowsVHDPath, windowsMachineVHDPath)
return CopyFile(windowsVHDPath, windowsMachineVHDPath)
}
// isLatest checks the latest release tag and
// reports whether the local ISO cache is the latest version.
//

View File

@ -216,10 +216,12 @@ func (m *mockReleaseGetter) download(dir, file, isoURL string) error {
}
type mockISO struct {
isopath string
exist bool
ver string
verCh <-chan string
isopath string
exist bool
ver string
vhdpath string
vhdexist bool
verCh <-chan string
}
func (m *mockISO) path() string {
@ -230,6 +232,14 @@ func (m *mockISO) exists() bool {
return m.exist
}
func (m *mockISO) pathVHD() string {
return m.vhdpath
}
func (m *mockISO) hasVHD() bool {
return m.vhdexist
}
func (m *mockISO) version() (string, error) {
select {
// receive version of a downloaded iso

View File

@ -0,0 +1,254 @@
/*
Copyright 2026 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 mcnutils
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"k8s.io/minikube/pkg/libmachine/log"
)
type ProgressWriter struct {
Total int64
Downloaded int64
mu sync.Mutex
TargetName string
}
func NewProgressWriter(total int64, targetName string) *ProgressWriter {
return &ProgressWriter{Total: total, TargetName: targetName}
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n := len(p)
pw.mu.Lock()
pw.Downloaded += int64(n)
pw.printProgress()
pw.mu.Unlock()
return n, nil
}
func (pw *ProgressWriter) printProgress() {
// Overwrite the same line with \r
fmt.Printf("\r > %s: %d / %d bytes complete", pw.TargetName, pw.Downloaded, pw.Total)
}
// copyLocalFile copies from a local source path to destination, reporting progress.
func copyLocalFile(srcPath, dstPath string) error {
srcFile, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open local source file %q: %w", srcPath, err)
}
defer srcFile.Close()
// Get total size
info, err := srcFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat local source file %q: %w", srcPath, err)
}
totalSize := info.Size()
outFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create destination file %q: %w", dstPath, err)
}
defer outFile.Close()
pw := NewProgressWriter(totalSize, filepath.Base(dstPath))
// Use TeeReader: read from srcFile, write to pw (for progress), and to outFile
_, err = io.Copy(outFile, io.TeeReader(srcFile, pw))
if err != nil {
return fmt.Errorf("error copying local file to %q: %w", dstPath, err)
}
// Final newline after progress
fmt.Printf("\n")
log.Infof("\t> Local copy complete: %s\n", dstPath)
return nil
}
// DownloadPart downloads a byte-range [start,end] of the URL into a temporary part file.
// On error, it returns the error; progress for the range is reported via pw.
// The caller goroutine must call wg.Done() exactly once.
func DownloadPart(urlStr string, start, end int64, partFileName string, pw *ProgressWriter, retryLimit int) error {
var resp *http.Response
var err error
// Retry loop for downloading a part
for retries := 0; retries <= retryLimit; retries++ {
req, errReq := http.NewRequest("GET", urlStr, nil)
if errReq != nil {
log.Errorf("Error creating request: %v", errReq)
return errReq
}
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
resp, err = http.DefaultClient.Do(req)
if err != nil {
log.Errorf("Error downloading part (attempt %d): %v", retries+1, err)
// Exponential backoff before retry
time.Sleep(time.Duration(1<<uint(retries)) * time.Second)
continue
}
// If we get Partial Content, proceed to save
if resp.StatusCode == http.StatusPartialContent {
partFile, errCreate := os.Create(partFileName)
if errCreate != nil {
log.Errorf("Error creating part file: %v", errCreate)
resp.Body.Close()
return errCreate
}
// Copy with progress
buf := make([]byte, 32*1024) // 32 KB buffer
_, errCopy := io.CopyBuffer(io.MultiWriter(partFile, pw), resp.Body, buf)
partFile.Close()
resp.Body.Close()
if errCopy != nil {
log.Errorf("Error saving part: %v", errCopy)
return errCopy
}
return nil // success
}
// Unexpected status => retry
log.Errorf("Error: expected status 206 Partial Content, got %d", resp.StatusCode)
resp.Body.Close()
time.Sleep(time.Duration(1<<uint(retries)) * time.Second)
}
log.Errorf("Failed to download part after %d retries", retryLimit)
return fmt.Errorf("failed to download part after %d retries", retryLimit)
}
// DownloadVHDX downloads a VHD from a URL or copies from a local path if detected.
// - urlStr: can be HTTP(S) URL or a local filesystem path (absolute or relative), or file:// URI.
// - filePath: destination path to write the VHD.
// - numParts: for HTTP downloads, number of parallel parts; ignored for local copy.
// - retryLimit: retry count per part.
//
// It returns an error on failure.
func DownloadVHDX(urlStr string, filePath string, numParts int, retryLimit int) error {
// First, check if urlStr is a local file path or file:// URI.
if u, err := url.Parse(urlStr); err == nil {
if u.Scheme == "file" {
// file:// URI: extract path
localPath := u.Path
// On Windows, file:///C:/path yields u.Path="/C:/path". Strip leading slash.
if strings.HasPrefix(localPath, "/") && os.PathSeparator == '\\' && len(localPath) > 2 && localPath[1] == ':' {
localPath = localPath[1:]
}
return copyLocalFile(localPath, filePath)
}
}
// If no scheme or non-file scheme, check if it's a path existing on disk:
if fi, err := os.Stat(urlStr); err == nil && !fi.IsDir() {
// Treat as local file
return copyLocalFile(urlStr, filePath)
}
// Otherwise assume HTTP(S) URL:
// First, HEAD to get total size
resp, err := http.Head(urlStr)
if err != nil {
return fmt.Errorf("failed to get file info from URL %q: %w", urlStr, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status from HEAD %q: %s", urlStr, resp.Status)
}
totalSize := resp.ContentLength
if totalSize <= 0 {
return fmt.Errorf("unknown content length for URL %q", urlStr)
}
// For progress display: use base name of destination
pw := NewProgressWriter(totalSize, filepath.Base(filePath))
// Partition download into numParts
partSize := totalSize / int64(numParts)
var wg sync.WaitGroup
var muErr sync.Mutex
downloadErrors := make([]error, 0, numParts)
partFiles := make([]string, numParts)
for i := 0; i < numParts; i++ {
start := int64(i) * partSize
end := start + partSize - 1
if i == numParts-1 {
end = totalSize - 1
}
partFileName := fmt.Sprintf("%s.part-%d.tmp", filePath, i)
partFiles[i] = partFileName
wg.Add(1)
go func(idx int, s, e int64, pfn string) {
defer wg.Done()
errPart := DownloadPart(urlStr, s, e, pfn, pw, retryLimit)
if errPart != nil {
muErr.Lock()
downloadErrors = append(downloadErrors, fmt.Errorf("part %d: %w", idx, errPart))
muErr.Unlock()
}
}(i, start, end, partFileName)
}
// Wait for all parts
wg.Wait()
if len(downloadErrors) > 0 {
// Clean up partial files
for _, pfn := range partFiles {
os.Remove(pfn)
}
return fmt.Errorf("download failed for parts: %v", downloadErrors)
}
// Merge parts
outFile, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create destination file %q: %w", filePath, err)
}
defer outFile.Close()
for _, pfn := range partFiles {
f, errOpen := os.Open(pfn)
if errOpen != nil {
return fmt.Errorf("failed to open part file %q: %w", pfn, errOpen)
}
_, errCopy := io.Copy(outFile, f)
f.Close()
if errCopy != nil {
return fmt.Errorf("failed to merge part file %q: %w", pfn, errCopy)
}
os.Remove(pfn)
}
// Final newline after progress
fmt.Printf("\n")
log.Infof("\t> Download complete: %s\n", filePath)
return nil
}

View File

@ -25,8 +25,42 @@ import (
"runtime"
"strconv"
"time"
"k8s.io/minikube/pkg/libmachine/log"
)
type GuestUtil struct {
os string
vhdUrl string
}
// ConfigGuest is the package-level singleton for GuestUtil
var ConfigGuest *GuestUtil
func SetGuestUtil(guestOS, vhdUrl string) {
ConfigGuest = &GuestUtil{
os: guestOS,
vhdUrl: vhdUrl,
}
log.Debugf("SetGuestUtil: os=%s, vhdUrl=%s", guestOS, vhdUrl)
}
func (g *GuestUtil) GetGuestOS() string {
if g == nil {
log.Debugf("GuestUtil is not initialized")
return "unknown"
}
return g.os
}
func (g *GuestUtil) GetVHDUrl() string {
if g == nil {
log.Debugf("GuestUtil is not initialized")
return ""
}
return g.vhdUrl
}
type MultiError struct {
Errs []error
}

View File

@ -19,6 +19,7 @@ package bootstrapper
import (
"time"
"k8s.io/minikube/pkg/libmachine/host"
"k8s.io/minikube/pkg/minikube/bootstrapper/images"
"k8s.io/minikube/pkg/minikube/config"
"k8s.io/minikube/pkg/minikube/constants"
@ -42,8 +43,11 @@ type Bootstrapper interface {
UpdateCluster(config.ClusterConfig) error
DeleteCluster(config.KubernetesConfig) error
WaitForNode(config.ClusterConfig, config.Node, time.Duration) error
SetupMinikubeCert(*host.Host) (string, error)
JoinClusterWindows(*host.Host, config.ClusterConfig, config.Node, string, time.Duration) (string, error)
JoinCluster(config.ClusterConfig, config.Node, string) error
UpdateNode(config.ClusterConfig, config.Node, cruntime.Manager) error
GenerateTokenWindows(config.ClusterConfig) (string, error)
GenerateToken(config.ClusterConfig) (string, error)
// LogCommands returns a map of log type to a command which will display that log.
LogCommands(config.ClusterConfig, LogOptions) map[string]string

View File

@ -63,6 +63,11 @@ type sharedCACerts struct {
// SetupCerts gets the generated credentials required to talk to the APIServer.
func SetupCerts(k8s config.ClusterConfig, n config.Node, pcpCmd command.Runner, cmd command.Runner) error {
// no need to setup certs for windows worker nodes as the master node already took care of this
if n.Guest.Name == "windows" {
return nil
}
localPath := localpath.Profile(k8s.KubernetesConfig.ClusterName)
klog.Infof("Setting up %s for IP: %s", localPath, n.IP)

View File

@ -42,6 +42,7 @@ import (
"k8s.io/minikube/pkg/drivers/kic/oci"
"k8s.io/minikube/pkg/kapi"
"k8s.io/minikube/pkg/libmachine"
"k8s.io/minikube/pkg/libmachine/host"
"k8s.io/minikube/pkg/libmachine/state"
"k8s.io/minikube/pkg/minikube/assets"
"k8s.io/minikube/pkg/minikube/bootstrapper"
@ -751,6 +752,78 @@ func (k *Bootstrapper) restartPrimaryControlPlane(cfg config.ClusterConfig) erro
return nil
}
//
//
func (k *Bootstrapper) SetupMinikubeCert(host *host.Host) (string, error) {
out.Step(style.Provisioning, "Setting up minikube certificates folder...")
certsDir := `C:\var\lib\minikube\certs`
k8sPkiDir := `C:\etc\kubernetes\pki`
caCert := `ca.crt`
script := fmt.Sprintf(
`mkdir %s; `+
`Copy-Item %s\%s -Destination %s; `+
`Remove-Item %s\%s`,
certsDir, k8sPkiDir, caCert, certsDir, k8sPkiDir, caCert,
)
script = strings.ReplaceAll(script, `"`, `\"`)
command := fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", script)
klog.Infof("[executing] : %v", command)
host.RunSSHCommand(command)
return "", nil
}
func (k *Bootstrapper) JoinClusterWindows(host *host.Host, cc config.ClusterConfig, n config.Node, joinCmd string, timeout time.Duration) (string, error) {
setLocationPath := `Set-Location -Path "C:\k"`
psScript := fmt.Sprintf("%s; %s", setLocationPath, joinCmd)
psScript = strings.ReplaceAll(psScript, `"`, `\"`)
command := fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", psScript)
klog.Infof("[executing] : %v", command)
// TODO: Explore how to make this better; channels for result and errors for now exist
resultChan := make(chan string, 1)
errorChan := make(chan error, 1)
go func() {
output, err := host.RunSSHCommand(command)
if err != nil {
errorChan <- err
return
}
resultChan <- output
}()
if timeout > 0 {
// If timeout is set, enforce it
select {
case result := <-resultChan:
return result, nil
case err := <-errorChan:
return "", err
case <-time.After(timeout):
return "", fmt.Errorf("operation timed out after %s", timeout)
}
} else {
// If no timeout is set, just wait for result or error
select {
case result := <-resultChan:
return result, nil
case err := <-errorChan:
return "", err
}
}
}
// JoinCluster adds new node to an existing cluster.
func (k *Bootstrapper) JoinCluster(cc config.ClusterConfig, n config.Node, joinCmd string) error {
// Join the control plane by specifying its token
@ -778,6 +851,29 @@ func (k *Bootstrapper) JoinCluster(cc config.ClusterConfig, n config.Node, joinC
return nil
}
// GenerateTokenWindows creates a token and returns the appropriate kubeadm join command to run, or the already existing token
func (k *Bootstrapper) GenerateTokenWindows(cc config.ClusterConfig) (string, error) {
tokenCmd := exec.Command("sudo", "/bin/bash", "-c", fmt.Sprintf("%s token create --print-join-command --ttl=0", bsutil.KubeadmCmdWithPath(cc.KubernetesConfig.KubernetesVersion)))
r, err := k.c.RunCmd(tokenCmd)
if err != nil {
return "", fmt.Errorf("generating join command: %w", err)
}
joinCmd := r.Stdout.String()
// log the join command for debugging purposes
klog.Infof("Generated join command ===: %s", joinCmd)
joinCmd = strings.Replace(joinCmd, "kubeadm", ".\\kubeadm.exe", 1)
joinCmd = fmt.Sprintf("%s --ignore-preflight-errors=all", strings.TrimSpace(joinCmd))
// append the cri-socket flag to the join command for windows
joinCmd = fmt.Sprintf("%s --cri-socket \"npipe:////./pipe/containerd-containerd\"", joinCmd)
// append --v=5 to the join command for windows
joinCmd = fmt.Sprintf("%s --v=5", joinCmd)
return joinCmd, nil
}
// GenerateToken creates a token and returns the appropriate kubeadm join command to run, or the already existing token
func (k *Bootstrapper) GenerateToken(cc config.ClusterConfig) (string, error) {
// Take that generated token and use it to get a kubeadm join command
@ -931,6 +1027,12 @@ func (k *Bootstrapper) UpdateCluster(cfg config.ClusterConfig) error {
// UpdateNode updates new or existing node.
func (k *Bootstrapper) UpdateNode(cfg config.ClusterConfig, n config.Node, r cruntime.Manager) error {
// skip if the node is a windows node
if n.Guest.Name == "windows" {
klog.Infof("skipping node %v update, as it is a windows node", n)
return nil
}
klog.Infof("updating node %v ...", n)
kubeletCfg, err := bsutil.NewKubeletConfig(cfg, n, r)

View File

@ -109,6 +109,7 @@ type ClusterConfig struct {
SSHAgentPID int
GPUs string
AutoPauseInterval time.Duration // Specifies interval of time to wait before checking if cluster should be paused
WindowsNodeVersion string // OS version of windows node
Rosetta bool // Only used by vfkit driver
}
@ -149,6 +150,7 @@ type Node struct {
ContainerRuntime string
ControlPlane bool
Worker bool
Guest Guest
}
// VersionedExtraOption holds information on flags to apply to a specific range
@ -182,3 +184,9 @@ type ScheduledStopConfig struct {
InitiationTime int64
Duration time.Duration
}
type Guest struct {
Name string
Version string
URL string
}

View File

@ -30,6 +30,8 @@ import (
var (
// SupportedArchitectures is the list of supported architectures
SupportedArchitectures = [4]string{"amd64", "arm64", "ppc64le", "s390x"}
// IP Address for the control plane
MasterNodeIP = ""
)
const (
@ -175,6 +177,13 @@ const (
// Mirror CN
AliyunMirror = "registry.cn-hangzhou.aliyuncs.com/google_containers"
// DefaultWindowsNodeVersion is the default version of Windows node
DefaultWindowsNodeVersion = "2025"
// DefaultWindowsVhdURL is the VHD download URL for Windows Server 2025.
// This will be used whenever the user does NOT supply --windows-vhd-url.
DefaultWindowsVhdURL = "https://minikubevhdimagebuider.blob.core.windows.net/versions/hybrid-minikube-windows-server.vhdx"
)
var (

View File

@ -117,6 +117,7 @@ func downloadISO(isoURL string, skipChecksum bool) error {
// Lock before we check for existence to avoid thundering herd issues
dst := localISOPath(u)
if err := os.MkdirAll(filepath.Dir(dst), 0777); err != nil {
return fmt.Errorf("making cache image directory: %s: %w", dst, err)
}

View File

@ -91,14 +91,19 @@ type LocalClient struct {
commandOptions *run.CommandOptions
}
// DefineGuest sets/tracks the guest OS for the host
func (api *LocalClient) DefineGuest(h *host.Host) {
api.legacyClient.DefineGuest(h)
}
// NewHost creates a new Host
func (api *LocalClient) NewHost(drvName string, rawDriver []byte) (*host.Host, error) {
func (api *LocalClient) NewHost(drvName string, guest host.Guest, rawDriver []byte) (*host.Host, error) {
def := registry.Driver(drvName)
if def.Empty() {
return nil, fmt.Errorf("driver %q does not exist", drvName)
}
if def.Init == nil {
return api.legacyClient.NewHost(drvName, rawDriver)
return api.legacyClient.NewHost(drvName, guest, rawDriver)
}
d := def.Init(api.commandOptions)
err := json.Unmarshal(rawDriver, d)
@ -111,6 +116,7 @@ func (api *LocalClient) NewHost(drvName string, rawDriver []byte) (*host.Host, e
Name: d.GetMachineName(),
Driver: d,
DriverName: d.DriverName(),
Guest: guest,
HostOptions: &host.Options{
AuthOptions: &auth.Options{
CertDir: api.certsDir,
@ -221,10 +227,14 @@ func (api *LocalClient) Create(h *host.Host) error {
{
"provisioning",
func() error {
// Skippable because we don't reconfigure Docker?
// Skipped because we don't reconfigure Docker?
if driver.BareMetal(h.Driver.DriverName()) {
return nil
}
// Skipped because we don't reconfigure Docker for Windows Host
if h.Guest.Name == "windows" {
return nil
}
return provisionDockerMachine(h)
},
},

View File

@ -24,6 +24,7 @@ import (
"testing"
"k8s.io/minikube/pkg/libmachine/drivers/plugin/localbinary"
"k8s.io/minikube/pkg/libmachine/host"
"k8s.io/minikube/pkg/minikube/driver"
_ "k8s.io/minikube/pkg/minikube/registry/drvs/virtualbox"
@ -71,19 +72,30 @@ func TestLocalClientNewHost(t *testing.T) {
var tests = []struct {
description string
driver string
guest host.Guest
rawDriver []byte
err bool
}{
{
description: "host vbox correct",
driver: driver.VirtualBox,
rawDriver: []byte(vboxConfig),
guest: host.Guest{
Name: "linux",
Version: "1.0.0",
URL: "https://example.com/linux.iso",
},
rawDriver: []byte(vboxConfig),
},
{
description: "host vbox incorrect",
driver: driver.VirtualBox,
rawDriver: []byte("?"),
err: true,
guest: host.Guest{
Name: "linux",
Version: "1.0.0",
URL: "https://example.com/linux.iso",
},
rawDriver: []byte("?"),
err: true,
},
}
@ -91,7 +103,7 @@ func TestLocalClientNewHost(t *testing.T) {
test := test
t.Run(test.description, func(t *testing.T) {
t.Parallel()
host, err := c.NewHost(test.driver, test.rawDriver)
host, err := c.NewHost(test.driver, test.guest, test.rawDriver)
// A few sanity checks that we can do on the host
if host != nil {
if host.DriverName != test.driver {
@ -100,6 +112,9 @@ func TestLocalClientNewHost(t *testing.T) {
if host.Name != host.Driver.GetMachineName() {
t.Errorf("Host name is not correct. Expected :%s, got: %s", host.Driver.GetMachineName(), host.Name)
}
if host.Guest.Name != test.guest.Name {
t.Errorf("Host's guest os is not correct. Expected :%s, got: %s", test.guest.Name, host.Guest.Name)
}
}
if err != nil && !test.err {
t.Errorf("Unexpected error: %v", err)

View File

@ -144,7 +144,7 @@ func createHost(api libmachine.API, cfg *config.ClusterConfig, n *config.Node) (
return nil, fmt.Errorf("marshal: %w", err)
}
h, err := api.NewHost(cfg.Driver, data)
h, err := api.NewHost(cfg.Driver, host.Guest(n.Guest), data)
if err != nil {
return nil, fmt.Errorf("new host: %w", err)
}
@ -154,11 +154,14 @@ func createHost(api libmachine.API, cfg *config.ClusterConfig, n *config.Node) (
h.HostOptions.AuthOptions.StorePath = localpath.MiniPath()
h.HostOptions.EngineOptions = engineOptions(*cfg)
api.DefineGuest(h)
cstart := time.Now()
klog.Infof("libmachine.API.Create for %q (driver=%q)", cfg.Name, cfg.Driver)
if cfg.StartHostTimeout == 0 {
cfg.StartHostTimeout = 6 * time.Minute
// increase default wait to accommodate slower hosts and Windows nodes
cfg.StartHostTimeout = 12 * time.Minute
}
if err := timedCreateHost(h, api, cfg.StartHostTimeout); err != nil {
return nil, fmt.Errorf("creating host: %w", err)
@ -182,6 +185,7 @@ func timedCreateHost(h *host.Host, api libmachine.API, t time.Duration) error {
create := make(chan error, 1)
go func() {
defer close(create)
klog.Infof("libmachine.API.Create starting for %q (GuestOS=%q)", h.Name, h.Guest.Name)
create <- api.Create(h)
}()
@ -299,6 +303,12 @@ func postStartSetup(h *host.Host, mc config.ClusterConfig) error {
return nil
}
// skip postStartSetup for windows guest os
if h.Guest.Name == "windows" {
klog.Infof("skipping postStartSetup for windows guest os")
return nil
}
k8sVer, err := semver.ParseTolerant(mc.KubernetesConfig.KubernetesVersion)
if err != nil {
klog.Errorf("unable to parse Kubernetes version: %s", mc.KubernetesConfig.KubernetesVersion)
@ -426,3 +436,26 @@ func addHostAliasCommand(name string, record string, sudo bool, destPath string)
destPath)
return exec.Command("/bin/bash", "-c", script)
}
func AddHostAliasWindows(host *host.Host, controlPlaneIP string) (string, error) {
out.Step(style.Provisioning, "Adding host alias for control plane ...")
path := "C:\\Windows\\System32\\drivers\\etc\\hosts"
entry := fmt.Sprintf("\t%s\tcontrol-plane.minikube.internal", controlPlaneIP)
psScript := fmt.Sprintf(
`$hostsContent = Get-Content -Path "%s" -Raw -ErrorAction SilentlyContinue; `+
`if ($hostsContent -notmatch [regex]::Escape("%s")) { `+
`Add-Content -Path "%s" -Value "%s" -Force | Out-Null }`,
path, entry, path, entry,
)
psScript = strings.ReplaceAll(psScript, `"`, `\"`)
command := fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", psScript)
klog.Infof("[executing] : %v", command)
host.RunSSHCommand(command)
return "", nil
}

View File

@ -57,6 +57,7 @@ func stop(h *host.Host) error {
}
if driver.NeedsShutdown(h.DriverName) {
klog.Infof("GuestOS: %s", h.Guest.Name)
if err := trySSHPowerOff(h); err != nil {
return fmt.Errorf("ssh power off: %w", err)
}

View File

@ -0,0 +1,80 @@
/*
Copyright 2026 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 node
import (
"bytes"
"errors"
"fmt"
"os/exec"
"strings"
"golang.org/x/crypto/ssh"
"k8s.io/klog/v2"
)
var powershell string
var (
ErrPowerShellNotFound = errors.New("powershell was not found in the path")
ErrNotAdministrator = errors.New("hyper-v commands have to be run as an Administrator")
ErrNotInstalled = errors.New("hyper-V PowerShell Module is not available")
)
func init() {
powershell, _ = exec.LookPath("powershell.exe")
}
func cmdOut(args ...string) (string, error) {
args = append([]string{"-NoProfile", "-NonInteractive"}, args...)
cmd := exec.Command(powershell, args...)
klog.Infof("[executing ==>] : %v %v", powershell, strings.Join(args, " "))
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
klog.Infof("[stdout =====>] : %s", stdout.String())
klog.Infof("[stderr =====>] : %s", stderr.String())
return stdout.String(), err
}
func cmd(args ...string) error {
_, err := cmdOut(args...)
return err
}
func CmdOutSSH(client *ssh.Client, script string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
command := fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", script)
klog.Infof("[executing] : %v", command)
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
err = session.Run(command)
klog.Infof("[stdout =====>] : %s", stdout.String())
klog.Infof("[stderr =====>] : %s", stderr.String())
return stdout.String(), err
}

View File

@ -110,35 +110,60 @@ func Start(starter Starter, options *run.CommandOptions) (*kubeconfig.Settings,
return nil, config.Write(viper.GetString(config.ProfileName), starter.Cfg)
}
// wait for preloaded tarball to finish downloading before configuring runtimes
waitCacheRequiredImages(&cacheGroup)
// log starter.Node.OS here
klog.Infof("Node OS: %s", starter.Node.Guest.Name)
if starter.Node.Guest.Name != "windows" {
// wait for preloaded tarball to finish downloading before configuring runtimes
waitCacheRequiredImages(&cacheGroup)
}
sv, err := util.ParseKubernetesVersion(starter.Node.KubernetesVersion)
if err != nil {
return nil, fmt.Errorf("Failed to parse Kubernetes version: %w", err)
}
klog.Infof("Kubernetes version: %s", sv)
// configure the runtime (docker, containerd, crio)
cr := configureRuntimes(starter.Runner, *starter.Cfg, sv)
var cr cruntime.Manager
if starter.Node.Guest.Name != "windows" {
// configure the runtime (docker, containerd, crio) only for windows nodes
cr = configureRuntimes(starter.Runner, *starter.Cfg, sv)
// check if installed runtime is compatible with current minikube code
if err = cruntime.CheckCompatibility(cr); err != nil {
return nil, err
// check if installed runtime is compatible with current minikube code
if err = cruntime.CheckCompatibility(cr); err != nil {
return nil, err
}
showVersionInfo(starter.Node.KubernetesVersion, cr)
}
showVersionInfo(starter.Node.KubernetesVersion, cr)
klog.Infof("configureRuntimes done: cr=%v", cr)
// add "host.minikube.internal" dns alias (intentionally non-fatal)
hostIP, err := cluster.HostIP(starter.Host, starter.Cfg.Name)
if err != nil {
klog.Errorf("Unable to get host IP: %v", err)
} else if err := machine.AddHostAlias(starter.Runner, constants.HostAlias, hostIP); err != nil {
klog.Errorf("Unable to add minikube host alias: %v", err)
}
if starter.Node.Guest.Name != "windows" {
if err := machine.AddHostAlias(starter.Runner, constants.HostAlias, hostIP); err != nil {
klog.Warningf("Unable to add host alias: %v", err)
}
} else {
out.Step(style.Provisioning, "Configuring Windows node...")
if stdout, err := machine.AddHostAliasWindows(starter.Host, constants.MasterNodeIP); err != nil {
klog.Warningf("Unable to add host alias: %v", err)
} else {
klog.Infof("Host alias added: %s", stdout)
}
}
var kcs *kubeconfig.Settings
var bs bootstrapper.Bootstrapper
if config.IsPrimaryControlPlane(*starter.Cfg, *starter.Node) {
constants.MasterNodeIP, err = starter.Host.Driver.GetIP()
if err != nil {
klog.Errorf("Unable to get driver IP: %v", err)
}
klog.Infof("Driver IP: %s", constants.MasterNodeIP)
// [re]start primary control-plane node
kcs, bs, err = startPrimaryControlPlane(starter, cr, options)
if err != nil {
@ -253,6 +278,30 @@ func Start(starter Starter, options *run.CommandOptions) (*kubeconfig.Settings,
addons.UpdateConfigToDisable(starter.Cfg, options)
}
// for windows node prepare the linux control plane node for windows-specific flannel CNI config
if config.IsPrimaryControlPlane(*starter.Cfg, *starter.Node) && starter.Cfg.WindowsNodeVersion == "2022" {
if err := prepareLinuxNode(starter.Runner); err != nil {
klog.Errorf("Failed to prepare Linux node for Windows-specific Flannel CNI config: %v", err)
}
// set up flannel network issues
if err := configureFlannelCNI(); err != nil {
klog.Errorf("error configuring flannel CNI: %v", err)
}
}
// for windows node prepare the linux control plane node for windows-specific flannel CNI config
if config.IsPrimaryControlPlane(*starter.Cfg, *starter.Node) && starter.Cfg.WindowsNodeVersion == "2022" {
if err := prepareLinuxNode(starter.Runner); err != nil {
klog.Errorf("Failed to prepare Linux node for Windows-specific Flannel CNI config: %v", err)
}
// set up flannel network issues
if err := configureFlannelCNI(); err != nil {
klog.Errorf("error configuring flannel CNI: %v", err)
}
}
// Write enabled addons to the config before completion
klog.Infof("writing updated cluster config ...")
return kcs, config.Write(viper.GetString(config.ProfileName), starter.Cfg)
@ -317,6 +366,7 @@ func startPrimaryControlPlane(starter Starter, cr cruntime.Manager, options *run
func joinCluster(starter Starter, cpBs bootstrapper.Bootstrapper, bs bootstrapper.Bootstrapper, options *run.CommandOptions) error {
start := time.Now()
klog.Infof("joinCluster: %+v", starter.Cfg)
out.Step(style.Waiting, "Joining {{.name}} to the cluster", out.V{"name": starter.Node.Name})
defer func() {
klog.Infof("duration metric: took %s to joinCluster", time.Since(start))
}()
@ -336,35 +386,93 @@ func joinCluster(starter Starter, cpBs bootstrapper.Bootstrapper, bs bootstrappe
klog.Infof("successfully removed existing %s node %q from cluster: %+v", role, starter.Node.Name, starter.Node)
}
joinCmd, err := cpBs.GenerateToken(*starter.Cfg)
if err != nil {
return fmt.Errorf("error generating join token: %w", err)
// declare joinCmd variable
var joinCmd string
var err error
// if node is a windows node, generate the join command
if starter.Node.Guest.Name == "windows" {
joinCmd, err = cpBs.GenerateTokenWindows(*starter.Cfg)
if err != nil {
return fmt.Errorf("error generating join token: %w", err)
}
} else {
joinCmd, err = cpBs.GenerateToken(*starter.Cfg)
if err != nil {
return fmt.Errorf("error generating join token: %w", err)
}
}
klog.Infof("join command: %s", joinCmd)
join := func() error {
klog.Infof("trying to join %s node %q to cluster: %+v", role, starter.Node.Name, starter.Node)
if err := bs.JoinCluster(*starter.Cfg, *starter.Node, joinCmd); err != nil {
klog.Errorf("%s node failed to join cluster, will retry: %v", role, err)
if starter.Node.Guest.Name != "windows" {
if err := bs.JoinCluster(*starter.Cfg, *starter.Node, joinCmd); err != nil {
// log the error message and retry
klog.Errorf("%s node failed to join cluster, will retry: %v", role, err)
// reset node to revert any changes made by previous kubeadm init/join
klog.Infof("resetting %s node %q before attempting to rejoin cluster...", role, starter.Node.Name)
if _, err := starter.Runner.RunCmd(exec.Command("sudo", "/bin/bash", "-c", fmt.Sprintf("%s reset --force", bsutil.KubeadmCmdWithPath(starter.Cfg.KubernetesConfig.KubernetesVersion)))); err != nil {
klog.Infof("kubeadm reset failed, continuing anyway: %v", err)
} else {
klog.Infof("successfully reset %s node %q", role, starter.Node.Name)
// reset node to revert any changes made by previous kubeadm init/join
klog.Infof("resetting %s node %q before attempting to rejoin cluster...", role, starter.Node.Name)
if _, err := starter.Runner.RunCmd(exec.Command("sudo", "/bin/bash", "-c", fmt.Sprintf("%s reset --force", bsutil.KubeadmCmdWithPath(starter.Cfg.KubernetesConfig.KubernetesVersion)))); err != nil {
klog.Infof("kubeadm reset failed, continuing anyway: %v", err)
} else {
klog.Infof("successfully reset %s node %q", role, starter.Node.Name)
}
return err
}
} else {
driverIP, err := starter.Host.Driver.GetIP()
if err != nil {
klog.Errorf("Unable to get driver IP: %v", err)
}
klog.Infof("Driver IP: %s", driverIP)
return err
// Call with a timeout of 30 seconds
timeout := 20 * time.Second
if commandResult, err := bs.JoinClusterWindows(starter.Host, *starter.Cfg, *starter.Node, joinCmd, timeout); err != nil {
klog.Infof("%s node failed to join cluster, will retry: %v", role, err)
klog.Infof("command result: %s", commandResult)
// sort out the certificates issues
if cmd, err := bs.SetupMinikubeCert(starter.Host); err != nil {
klog.Errorf("error setting minikube folder error script: %v", err)
} else {
klog.Infof("command result: %s", cmd)
// retry the join command
if commandResult, err := bs.JoinClusterWindows(starter.Host, *starter.Cfg, *starter.Node, joinCmd, 0); err != nil {
klog.Errorf("error retrying join command: %v, command result: %s", err, commandResult)
return err
}
// set up flannel network issues
if err := prepareWindowsNodeFlannel(); err != nil {
klog.Errorf("error preparing windows node flannel: %v", err)
}
// set up kube-proxy issues
if err := prepareWindowsNodeKubeProxy(); err != nil {
klog.Errorf("error preparing windows node kube-proxy: %v", err)
}
}
// return err
}
}
return nil
}
if err := retry.Expo(join, 10*time.Second, 3*time.Minute); err != nil {
return fmt.Errorf("error joining %s node %q to cluster: %w", role, starter.Node.Name, err)
if starter.Node.Guest.Name != "windows" {
return fmt.Errorf("error joining %s node %q to cluster: %w", role, starter.Node.Name, err)
}
}
if err := cpBs.LabelAndUntaintNode(*starter.Cfg, *starter.Node); err != nil {
return fmt.Errorf("error applying %s node %q label: %w", role, starter.Node.Name, err)
}
return nil
}
@ -679,11 +787,15 @@ func startMachine(cfg *config.ClusterConfig, node *config.Node, delOnFail bool,
if err != nil {
return runner, preExists, m, hostInfo, fmt.Errorf("Failed to get command runner: %w", err)
}
// log that we managed to get a command runner
klog.Infof("CommandRunner returned: %v", runner)
ip, err := validateNetwork(hostInfo, runner, cfg.KubernetesConfig.ImageRepository)
if err != nil {
return runner, preExists, m, hostInfo, fmt.Errorf("Failed to validate network: %w", err)
}
// log that we managed to validate the network
klog.Infof("validateNetwork returned: %v", ip)
if driver.IsQEMU(hostInfo.Driver.DriverName()) && network.IsBuiltinQEMU(cfg.Network) {
apiServerPort, err := getPort()
@ -699,6 +811,12 @@ func startMachine(cfg *config.ClusterConfig, node *config.Node, delOnFail bool,
out.FailureT("Failed to set NO_PROXY Env. Please use `export NO_PROXY=$NO_PROXY,{{.ip}}`.", out.V{"ip": ip})
}
// log that we managed to exclude the IP from the proxy
klog.Infof("Excluded IP from proxy: %v", ip)
// log the result of the function
klog.Infof("startMachine returned: %v, %v, %v, %v", runner, preExists, m, hostInfo)
return runner, preExists, m, hostInfo, err
}
@ -942,6 +1060,52 @@ func prepareNone() {
}
}
func configureFlannelCNI() error {
err := cmd("kubectl apply -f https://raw.githubusercontent.com/vrapolinario/MinikubeWindowsContainers/main/kube-flannel.yaml")
if err != nil {
klog.Errorf("failed to apply kube-flannel configuration: %v\n", err)
}
roll_err := cmd("kubectl rollout restart ds kube-flannel-ds -n kube-flannel")
if roll_err != nil {
klog.Errorf("failed to restart kube-flannel daemonset: %v\n", roll_err)
}
klog.Infof("Successfully applied the configuration.")
return nil
}
// prepare windows node by flannel configuration
func prepareWindowsNodeFlannel() error {
err := cmd("kubectl apply -f https://raw.githubusercontent.com/vrapolinario/MinikubeWindowsContainers/main/flannel-overlay.yaml")
if err != nil {
klog.Errorf("failed to apply flannel configuration: %v\n", err)
}
klog.Infof("Successfully applied the configuration.")
return nil
}
// prepare linux nodes for Windows-specific Flannel CNI config
func prepareLinuxNode(runner command.Runner) error {
c := exec.Command("sudo", "sysctl", "net.bridge.bridge-nf-call-iptables=1")
if rr, err := runner.RunCmd(c); err != nil {
klog.Infof("couldn't run %q command. error: %v", rr.Command(), err)
}
// log that we managed to run the command
klog.Infof("Successfully ran the command.")
return nil
}
// prepare windows node kube-proxy yaml configuration
func prepareWindowsNodeKubeProxy() error {
err := cmd("kubectl apply -f https://raw.githubusercontent.com/vrapolinario/MinikubeWindowsContainers/main/kube-proxy.yaml")
if err != nil {
klog.Errorf("failed to apply kube-proxy configuration: %v\n", err)
}
klog.Infof("Successfully applied the configuration.")
return nil
}
// addCoreDNSEntry adds host name and IP record to the DNS by updating CoreDNS's ConfigMap.
// ref: https://coredns.io/plugins/hosts/
// note: there can be only one 'hosts' block in CoreDNS's ConfigMap (avoid "plugin/hosts: this plugin can only be used once per Server Block" error)
@ -1021,3 +1185,15 @@ To see benchmarks checkout https://minikube.sigs.k8s.io/docs/benchmarks/cpuusage
`, out.V{"drivers": altDriverList.String()})
}
}
// ValidWindowsOSVersions lists the supported Windows OS versions
func ValidWindowsOSVersions() map[string]bool {
// TODO: add more versions as they are tested and supported
// return map[string]bool{"2019": true, "2022": true, "2025": true}
return map[string]bool{"2025": true}
}
// ValidOS lists the supported OSes
func ValidOS() []string {
return []string{"linux", "windows"}
}

View File

@ -69,8 +69,13 @@ func (api *MockAPI) Close() error {
return nil
}
// DefineGuest sets/tracks the guest OS for the host
func (api *MockAPI) DefineGuest(h *host.Host) {
api.Logf("MockAPI.DefineGuest: guest=%q", h.Guest.Name)
}
// NewHost creates a new host.Host instance.
func (api *MockAPI) NewHost(drvName string, rawDriver []byte) (*host.Host, error) {
func (api *MockAPI) NewHost(drvName string, guest host.Guest, rawDriver []byte) (*host.Host, error) {
var driver MockDriver
if err := json.Unmarshal(rawDriver, &driver); err != nil {
return nil, fmt.Errorf("error unmarshalling json: %w", err)
@ -81,6 +86,7 @@ func (api *MockAPI) NewHost(drvName string, rawDriver []byte) (*host.Host, error
RawDriver: rawDriver,
Driver: &MockDriver{},
Name: fmt.Sprintf("mock-machine-%.8f", rand.Float64()),
Guest: guest,
HostOptions: &host.Options{
AuthOptions: &auth.Options{},
SwarmOptions: &swarm.Options{},

View File

@ -122,6 +122,7 @@ minikube start [flags]
--vm Filter to use only VM Drivers
--wait strings comma separated list of Kubernetes components to verify and wait for after starting a cluster. defaults to "apiserver,system_pods", available options: "apiserver,system_pods,default_sa,apps_running,node_ready,kubelet,extra" . other acceptable values are 'all' or 'none', 'true' and 'false' (default [apiserver,system_pods])
--wait-timeout duration max time to wait per Kubernetes or host to be healthy. (default 6m0s)
--windows-node-version string The version of Windows to use for the windows node on a multi-node cluster (e.g., 2025). Currently support Windows Server 2025
```
### Options inherited from parent commands

View File

@ -1,3 +1,19 @@
/*
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 main
import (

View File

@ -1,3 +1,19 @@
/*
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 main
import (