From 1699f17936f35b86f39e59b42db695f06108bc46 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Thu, 11 Sep 2025 15:35:44 +0300 Subject: [PATCH 1/2] Initial windows configurer Signed-off-by: Kimmo Lehto --- configurer/windows.go | 279 ++++++++++++++++++++++++++++++++++ configurer/windows/windows.go | 25 +++ 2 files changed, 304 insertions(+) create mode 100644 configurer/windows.go create mode 100644 configurer/windows/windows.go diff --git a/configurer/windows.go b/configurer/windows.go new file mode 100644 index 00000000..fe73b6f1 --- /dev/null +++ b/configurer/windows.go @@ -0,0 +1,279 @@ +package configurer + +import ( + "bufio" + "fmt" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/k0sproject/rig/exec" + "github.com/k0sproject/rig/os" + ps "github.com/k0sproject/rig/pkg/powershell" + "github.com/k0sproject/version" +) + +// Windows provides helpers and defaults for Windows hosts +type BaseWindows struct { + paths map[string]string + pathMu sync.Mutex +} + +func (w *BaseWindows) initPaths() { + if w.paths != nil { + return + } + w.paths = map[string]string{ + "K0sBinaryPath": `C:\\Program Files\\k0s\\k0s.exe`, + "K0sConfigPath": `C:\\ProgramData\\k0s\\k0s.yaml`, + "K0sJoinTokenPath": `C:\\ProgramData\\k0s\\k0stoken`, + "DataDirDefaultPath": `C:\\ProgramData\\k0s`, + } +} + +// K0sBinaryPath returns the path to the k0s binary on the host +func (w *BaseWindows) K0sBinaryPath() string { + w.pathMu.Lock() + defer w.pathMu.Unlock() + w.initPaths() + return w.paths["K0sBinaryPath"] +} + +// K0sConfigPath returns the path to the k0s config file on the host +func (w *BaseWindows) K0sConfigPath() string { + w.pathMu.Lock() + defer w.pathMu.Unlock() + w.initPaths() + return w.paths["K0sConfigPath"] +} + +// K0sJoinTokenPath returns the path to the k0s join token file on the host +func (w *BaseWindows) K0sJoinTokenPath() string { + w.pathMu.Lock() + defer w.pathMu.Unlock() + w.initPaths() + return w.paths["K0sJoinTokenPath"] +} + +// DataDirDefaultPath returns the path to the k0s data dir on the host +func (w *BaseWindows) DataDirDefaultPath() string { + w.pathMu.Lock() + defer w.pathMu.Unlock() + w.initPaths() + return w.paths["DataDirDefaultPath"] +} + +// SetPath sets a path for a key +func (w *BaseWindows) SetPath(key, value string) { + w.pathMu.Lock() + defer w.pathMu.Unlock() + w.initPaths() + w.paths[key] = value +} + +// Arch returns the host processor architecture in the format k0s expects it +func (w *BaseWindows) Arch(h os.Host) (string, error) { + arch, err := h.ExecOutput(ps.Cmd(`$env:PROCESSOR_ARCHITECTURE`)) + if err != nil { + return "", err + } + + switch strings.ToUpper(strings.TrimSpace(arch)) { + case "AMD64", "X86_64": + return "amd64", nil + case "ARM64", "AARCH64": + return "arm64", nil + case "X86", "386", "I386": + return "386", nil + default: + return strings.ToLower(strings.TrimSpace(arch)), nil + } +} + +// K0sCmdf can be used to construct k0s commands in sprintf style. +func (w *BaseWindows) K0sCmdf(template string, args ...interface{}) string { + return fmt.Sprintf(`& %s %s`, ps.DoubleQuotePath(w.K0sBinaryPath()), fmt.Sprintf(template, args...)) +} + +func (w *BaseWindows) K0sBinaryVersion(h os.Host) (*version.Version, error) { + k0sVersionCmd := w.K0sCmdf("version") + output, err := h.ExecOutput(k0sVersionCmd) + if err != nil { + return nil, err + } + + ver, err := version.NewVersion(strings.TrimSpace(output)) + if err != nil { + return nil, err + } + return ver, nil +} + +// K0sctlLockFilePath returns a path to a lock file +func (w *BaseWindows) K0sctlLockFilePath(h os.Host) string { + // Use a system-wide temp location + return `C:\\Windows\\Temp\\k0sctl.lock` +} + +// TempFile returns a temp file path +func (w *BaseWindows) TempFile(h os.Host) (string, error) { + // Use .NET to generate a temp file path + return h.ExecOutput(ps.Cmd(`[System.IO.Path]::GetTempFileName()`)) +} + +// TempDir returns a temp dir path +func (w *BaseWindows) TempDir(h os.Host) (string, error) { + // Create a unique temp directory and output its path + script := `$p = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()); New-Item -ItemType Directory -Path $p | Out-Null; Write-Output $p` + return h.ExecOutput(ps.Cmd(script)) +} + +// DownloadURL performs a download from a URL on the host +func (w *BaseWindows) DownloadURL(h os.Host, url, destination string, opts ...exec.Option) error { + cmd := ps.Cmd(fmt.Sprintf(`Invoke-WebRequest -UseBasicParsing -Uri %s -OutFile %s`, ps.SingleQuote(url), ps.DoubleQuotePath(destination))) + if err := h.Exec(cmd, opts...); err != nil { + return fmt.Errorf("download failed: %w", err) + } + return nil +} + +// DownloadK0s performs k0s binary download from github on the host +func (w *BaseWindows) DownloadK0s(h os.Host, path string, versionV *version.Version, arch string, opts ...exec.Option) error { + v := strings.ReplaceAll(strings.TrimPrefix(versionV.String(), "v"), "+", "%2B") + url := fmt.Sprintf("https://github.com/k0sproject/k0s/releases/download/v%[1]s/k0s-v%[1]s-%[2]s.exe", v, arch) + if err := w.DownloadURL(h, url, path, opts...); err != nil { + return fmt.Errorf("failed to download k0s - check connectivity and k0s version validity: %w", err) + } + return nil +} + +// ReplaceK0sTokenPath replaces the config path in the service stub +func (w *BaseWindows) ReplaceK0sTokenPath(h os.Host, spath string) error { + // Replace literal REPLACEME with actual token path + cmd := ps.Cmd(fmt.Sprintf(`(Get-Content -Path %s) -replace 'REPLACEME', %s | Set-Content -Path %s -Encoding ascii`, ps.DoubleQuotePath(spath), ps.SingleQuote(w.K0sJoinTokenPath()), ps.DoubleQuotePath(spath))) + return h.Exec(cmd) +} + +// FileContains returns true if a file contains the substring +func (w *BaseWindows) FileContains(h os.Host, path, s string) bool { + cmd := ps.Cmd(fmt.Sprintf(`if (Select-String -Path %s -Pattern %s -SimpleMatch -Quiet) { exit 0 } else { exit 1 }`, ps.DoubleQuotePath(path), ps.SingleQuote(s))) + return h.Exec(cmd) == nil +} + +// MoveFile moves a file on the host +func (w *BaseWindows) MoveFile(h os.Host, src, dst string) error { + return h.Exec(ps.Cmd(fmt.Sprintf(`Move-Item -Force -Path %s -Destination %s`, ps.DoubleQuotePath(src), ps.DoubleQuotePath(dst)))) +} + +// KubeconfigPath returns the path to a kubeconfig on the host +func (w *BaseWindows) KubeconfigPath(h os.Host, dataDir string) string { + win := &os.Windows{} + adminConfPath := filepath.Join(dataDir, "pki", "admin.conf") + if win.FileExist(h, adminConfPath) { + return adminConfPath + } + return filepath.Join(dataDir, "kubelet.conf") +} + +// KubectlCmdf returns a command line in sprintf manner for running kubectl on the host using the kubeconfig from KubeconfigPath +func (w *BaseWindows) KubectlCmdf(h os.Host, dataDir, s string, args ...interface{}) string { + return fmt.Sprintf(`$env:KUBECONFIG=%s; %s`, ps.DoubleQuotePath(w.KubeconfigPath(h, dataDir)), w.K0sCmdf(`kubectl %s`, fmt.Sprintf(s, args...))) +} + +// HTTPStatus makes a HTTP GET request to the url and returns the status code or an error +func (w *BaseWindows) HTTPStatus(h os.Host, url string) (int, error) { + out, err := h.ExecOutput(ps.Cmd(fmt.Sprintf(`[int][System.Net.WebRequest]::Create(%s).GetResponse().StatusCode`, ps.SingleQuote(url)))) + if err != nil { + return -1, fmt.Errorf("failed to get HTTP status code: %w", err) + } + code, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return -1, fmt.Errorf("invalid response: %w", err) + } + return code, nil +} + +// PrivateInterface tries to find a private network interface (not implemented for Windows yet) +func (w *BaseWindows) PrivateInterface(h os.Host) (string, error) { + out, err := h.ExecOutput(ps.Cmd(`(Get-NetConnectionProfile -NetworkCategory Private | Select-Object -First 1).InterfaceAlias`)) + if err != nil || strings.TrimSpace(out) == "" { + out, err = h.ExecOutput(ps.Cmd(`(Get-NetConnectionProfile | Select-Object -First 1).InterfaceAlias`)) + } + if err != nil || strings.TrimSpace(out) == "" { + return "", fmt.Errorf("failed to detect a private network interface, define the host privateInterface manually: %w", err) + } + sc := bufio.NewScanner(strings.NewReader(out)) + if sc.Scan() { + return strings.TrimSpace(sc.Text()), nil + } + return "", fmt.Errorf("failed to detect a private network interface") +} + +// PrivateAddress resolves internal ip from private interface (not implemented for Windows yet) +func (w *BaseWindows) PrivateAddress(h os.Host, iface, publicip string) (string, error) { + ip, err := h.ExecOutput(ps.Cmd(fmt.Sprintf(`(Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias %s).IPAddress`, ps.SingleQuote(iface)))) + if err != nil || strings.TrimSpace(ip) == "" { + if !strings.HasPrefix(iface, "vEthernet") { + ve := fmt.Sprintf("vEthernet (%s)", iface) + ip, err = h.ExecOutput(ps.Cmd(fmt.Sprintf(`(Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias %s).IPAddress`, ps.SingleQuote(ve)))) + } + } + if err != nil { + return "", fmt.Errorf("failed to get IP address for interface %s: %w", iface, err) + } + addr := strings.TrimSpace(ip) + if addr != "" && addr != publicip { + return addr, nil + } + return "", fmt.Errorf("not found") +} + +// UpsertFile creates a file in path with content only if the file does not exist already +func (w *BaseWindows) UpsertFile(h os.Host, path, content string) error { + tmpf, err := w.TempFile(h) + if err != nil { + return err + } + // Write content to temp file + if err := h.Exec(ps.Cmd(fmt.Sprintf(`Set-Content -Path %s -Value @' +%s +'@ -Encoding ascii`, ps.DoubleQuotePath(tmpf), content))); err != nil { + return err + } + + // Atomically move if destination does not exist + script := ps.Cmd(fmt.Sprintf(`if (!(Test-Path -Path %s)) { Move-Item -Path %s -Destination %s } else { Remove-Item -Path %s -Force }`, ps.DoubleQuotePath(path), ps.DoubleQuotePath(tmpf), ps.DoubleQuotePath(path), ps.DoubleQuotePath(tmpf))) + if err := h.Exec(script); err != nil { + return fmt.Errorf("upsert failed: %w", err) + } + + // Ensure temp file is gone + if h.Exec(ps.Cmd(fmt.Sprintf(`Test-Path -Path %s`, ps.DoubleQuotePath(tmpf)))) == nil { + return fmt.Errorf("upsert failed") + } + return nil +} + +func (w *BaseWindows) DeleteDir(h os.Host, path string, opts ...exec.Option) error { + return h.Exec(ps.Cmd(fmt.Sprintf(`Remove-Item -Recurse -Force -Path %s`, ps.DoubleQuotePath(path))), opts...) +} + +func (w *BaseWindows) MachineID(h os.Host) (string, error) { + return h.ExecOutput(ps.Cmd(`(Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid).MachineGuid`)) +} + +// SystemTime returns the system time as UTC reported by the OS or an error if this fails +func (w *BaseWindows) SystemTime(h os.Host) (time.Time, error) { + out, err := h.ExecOutput(ps.Cmd(`[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()`)) + if err != nil { + return time.Time{}, fmt.Errorf("failed to get system time: %w", err) + } + out = strings.TrimSpace(out) + var unixTime int64 + if _, scanErr := fmt.Sscanf(out, "%d", &unixTime); scanErr != nil { + return time.Time{}, fmt.Errorf("failed to parse system time: %v", scanErr) + } + return time.Unix(unixTime, 0), nil +} diff --git a/configurer/windows/windows.go b/configurer/windows/windows.go new file mode 100644 index 00000000..66be3b87 --- /dev/null +++ b/configurer/windows/windows.go @@ -0,0 +1,25 @@ +package windows + +import ( + "github.com/k0sproject/k0sctl/configurer" + "github.com/k0sproject/rig" + "github.com/k0sproject/rig/os" + "github.com/k0sproject/rig/os/registry" +) + +// Windows provides OS support for Windows systems +type Windows struct { + os.Windows + configurer.BaseWindows +} + +func init() { + registry.RegisterOSModule( + func(osv rig.OSVersion) bool { + return osv.ID == "windows" + }, + func() interface{} { + return &Windows{} + }, + ) +} From 20870709af734f71a28abb4616da6af5b8802d7a Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Thu, 11 Sep 2025 16:14:43 +0300 Subject: [PATCH 2/2] [WIP] Windows worker provisioning Signed-off-by: Kimmo Lehto --- action/backup.go | 2 +- configurer/linux.go | 72 ++++++++++++++++--- configurer/windows.go | 61 +++++++++++++--- phase/backup.go | 39 +++++----- phase/configure_k0s.go | 24 ++++--- phase/download_k0s.go | 23 +++--- phase/install_binaries.go | 5 +- phase/upload_k0s.go | 34 +++++---- phase/uploadfiles.go | 68 +++++++++--------- phase/validate_hosts.go | 63 ++++++++-------- .../v1beta1/cluster/host.go | 49 +++++++------ 11 files changed, 283 insertions(+), 157 deletions(-) diff --git a/action/backup.go b/action/backup.go index 1be7557a..d6b5e059 100644 --- a/action/backup.go +++ b/action/backup.go @@ -13,7 +13,7 @@ import ( type Backup struct { // Manager is the phase manager Manager *phase.Manager - Out io.Writer + Out io.Writer } func (b Backup) Run(ctx context.Context) error { diff --git a/configurer/linux.go b/configurer/linux.go index c49ce074..9cad0ccb 100644 --- a/configurer/linux.go +++ b/configurer/linux.go @@ -1,12 +1,14 @@ package configurer import ( - "fmt" - "path" - "regexp" - "strconv" - "strings" - "sync" + "fmt" + "io" + "io/fs" + "path" + "regexp" + "strconv" + "strings" + "sync" "time" "al.essio.dev/pkg/shellescape" @@ -191,7 +193,20 @@ func (l *Linux) FileContains(h os.Host, path, s string) bool { // MoveFile moves a file on the host func (l *Linux) MoveFile(h os.Host, src, dst string) error { - return h.Execf(`mv "%s" "%s"`, src, dst, exec.Sudo(h)) + return h.Execf(`mv "%s" "%s"`, src, dst, exec.Sudo(h)) +} + +// Chown sets owner for a file or directory +func (l *Linux) Chown(h os.Host, path, owner string, opts ...exec.Option) error { + var args []interface{} + args = append(args, shellescape.Quote(owner)) + args = append(args, shellescape.Quote(path)) + // include any options passed in, plus sudo by default + for _, o := range opts { + args = append(args, o) + } + args = append(args, exec.Sudo(h)) + return h.Execf(`chown %s %s`, args...) } // KubeconfigPath returns the path to a kubeconfig on the host @@ -299,11 +314,50 @@ func (l *Linux) UpsertFile(h os.Host, path, content string) error { } func (l *Linux) DeleteDir(h os.Host, path string, opts ...exec.Option) error { - return h.Exec(fmt.Sprintf(`rmdir %s`, shellescape.Quote(path)), opts...) + return h.Exec(fmt.Sprintf(`rmdir %s`, shellescape.Quote(path)), opts...) } func (l *Linux) MachineID(h os.Host) (string, error) { - return h.ExecOutput(`cat /etc/machine-id || cat /var/lib/dbus/machine-id`) + return h.ExecOutput(`cat /etc/machine-id || cat /var/lib/dbus/machine-id`) +} + +// ListDir returns file and directory names in the given directory (not recursive) +func (l *Linux) ListDir(h os.Host, dir string) ([]string, error) { + // Use remote FS (with sudo) to read directory entries + entries, err := fs.ReadDir(h.SudoFsys(), dir) + if err != nil { + return nil, err + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Name()) + } + return out, nil +} + +// StreamFile writes the contents of a remote file to the provided writer +func (l *Linux) StreamFile(h os.Host, path string, w io.Writer, opts ...exec.Option) error { + // Prefer using remote FS to stream file contents + f, err := h.SudoFsys().Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err +} + +// Chmod changes the permission bits of the given path using remote FS +func (l *Linux) Chmod(h os.Host, path, mode string, opts ...exec.Option) error { + // Parse an octal mode string like "0755" + if mode == "" { + return nil + } + if v, err := strconv.ParseUint(mode, 8, 32); err == nil { + return h.SudoFsys().Chmod(path, fs.FileMode(v)) + } + // Fallback to invoking chmod if parsing failed + return h.Execf(`chmod %s "%s"`, mode, path, exec.Sudo(h)) } // SystemTime returns the system time as UTC reported by the OS or an error if this fails diff --git a/configurer/windows.go b/configurer/windows.go index fe73b6f1..66bf818f 100644 --- a/configurer/windows.go +++ b/configurer/windows.go @@ -1,13 +1,15 @@ package configurer import ( - "bufio" - "fmt" - "path/filepath" - "strconv" - "strings" - "sync" - "time" + "bufio" + "fmt" + "io" + "io/fs" + "path/filepath" + "strconv" + "strings" + "sync" + "time" "github.com/k0sproject/rig/exec" "github.com/k0sproject/rig/os" @@ -164,7 +166,50 @@ func (w *BaseWindows) FileContains(h os.Host, path, s string) bool { // MoveFile moves a file on the host func (w *BaseWindows) MoveFile(h os.Host, src, dst string) error { - return h.Exec(ps.Cmd(fmt.Sprintf(`Move-Item -Force -Path %s -Destination %s`, ps.DoubleQuotePath(src), ps.DoubleQuotePath(dst)))) + return h.Exec(ps.Cmd(fmt.Sprintf(`Move-Item -Force -Path %s -Destination %s`, ps.DoubleQuotePath(src), ps.DoubleQuotePath(dst)))) +} + +// Chown is a no-op on Windows; ownership semantics differ and are not managed here +func (w *BaseWindows) Chown(h os.Host, path, owner string, opts ...exec.Option) error { + return nil +} + +// ListDir returns file and directory names in the given directory (not recursive) +func (w *BaseWindows) ListDir(h os.Host, dir string) ([]string, error) { + // Use remote FS to read directory entries (sudo has no effect on Windows) + entries, err := fs.ReadDir(h.SudoFsys(), dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names, nil +} + +// StreamFile writes the contents of a remote file to the provided writer +func (w *BaseWindows) StreamFile(h os.Host, path string, wr io.Writer, opts ...exec.Option) error { + // Stream file contents using remote FS + f, err := h.SudoFsys().Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(wr, f) + return err +} + +// Chmod changes file attributes on Windows via rig FS (best-effort) +func (w *BaseWindows) Chmod(h os.Host, path, mode string, opts ...exec.Option) error { + if mode == "" { + return nil + } + if v, err := strconv.ParseUint(mode, 8, 32); err == nil { + return h.SudoFsys().Chmod(path, fs.FileMode(v)) + } + // Ignore invalid modes silently since Windows does not use POSIX perms + return nil } // KubeconfigPath returns the path to a kubeconfig on the host diff --git a/phase/backup.go b/phase/backup.go index d6268779..67d607ae 100644 --- a/phase/backup.go +++ b/phase/backup.go @@ -83,17 +83,20 @@ func (p *Backup) Run(_ context.Context) error { return err } - // get the name of the backup file - var remoteFile string - if p.IsWet() { - r, err := h.ExecOutputf(`ls "%s"`, backupDir) - if err != nil { - return err - } - remoteFile = r - } else { - remoteFile = "k0s_backup.dryrun.tar.gz" - } + // get the name of the backup file + var remoteFile string + if p.IsWet() { + entries, err := h.Configurer.ListDir(h, backupDir) + if err != nil { + return err + } + if len(entries) == 0 { + return fmt.Errorf("no backup file found in %s", backupDir) + } + remoteFile = entries[0] + } else { + remoteFile = "k0s_backup.dryrun.tar.gz" + } remotePath := path.Join(backupDir, remoteFile) defer func() { @@ -110,12 +113,12 @@ func (p *Backup) Run(_ context.Context) error { } }() - if p.IsWet() { - if err := h.Execf(`cat "%s"`, remotePath, exec.Writer(p.Out)); err != nil { - return fmt.Errorf("download backup: %w", err) - } - } else { - p.DryMsgf(nil, "download the backup file to local host") - } + if p.IsWet() { + if err := h.Configurer.StreamFile(h, remotePath, p.Out); err != nil { + return fmt.Errorf("download backup: %w", err) + } + } else { + p.DryMsgf(nil, "download the backup file to local host") + } return nil } diff --git a/phase/configure_k0s.go b/phase/configure_k0s.go index 83bd8998..cb0d0108 100644 --- a/phase/configure_k0s.go +++ b/phase/configure_k0s.go @@ -284,15 +284,21 @@ func (p *ConfigureK0s) configureK0s(ctx context.Context, h *cluster.Host) error configPath := h.K0sConfigPath() configDir := gopath.Dir(configPath) - if !h.Configurer.FileExist(h, configDir) { - if err := h.Execf(`install -m 0750 -o root -g root -d "%s"`, configDir, exec.Sudo(h)); err != nil { - return fmt.Errorf("failed to create k0s configuration directory: %w", err) - } - } - - if err := h.Execf(`install -m 0600 -o root -g root "%s" "%s"`, tempConfigPath, configPath, exec.Sudo(h)); err != nil { - return fmt.Errorf("failed to install k0s configuration: %w", err) - } + if !h.Configurer.FileExist(h, configDir) { + if err := h.Configurer.MkDir(h, configDir, exec.Sudo(h)); err != nil { + return fmt.Errorf("failed to create k0s configuration directory: %w", err) + } + if err := h.Configurer.Chmod(h, configDir, "0750", exec.Sudo(h)); err != nil { + log.Debugf("%s: failed to chmod %s: %v", h, configDir, err) + } + } + + if err := h.Configurer.MoveFile(h, tempConfigPath, configPath); err != nil { + return fmt.Errorf("failed to install k0s configuration: %w", err) + } + if err := h.Configurer.Chmod(h, configPath, "0600", exec.Sudo(h)); err != nil { + log.Debugf("%s: failed to chmod %s: %v", h, configPath, err) + } if h.Metadata.K0sRunningVersion != nil && !h.Metadata.NeedsUpgrade { log.Infof("%s: restarting k0s service", h) diff --git a/phase/download_k0s.go b/phase/download_k0s.go index 05efaab2..3811e489 100644 --- a/phase/download_k0s.go +++ b/phase/download_k0s.go @@ -1,10 +1,11 @@ package phase import ( - "context" - "fmt" - "strconv" - "time" + "context" + "fmt" + "strconv" + "time" + "strings" "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" @@ -61,7 +62,14 @@ func (p *DownloadK0s) Run(ctx context.Context) error { } func (p *DownloadK0s) downloadK0s(_ context.Context, h *cluster.Host) error { - tmp := h.Configurer.K0sBinaryPath() + ".tmp." + strconv.Itoa(int(time.Now().UnixNano())) + ts := strconv.Itoa(int(time.Now().UnixNano())) + bin := h.Configurer.K0sBinaryPath() + tmp := bin + ".tmp." + ts + if h.IsConnected() && h.IsWindows() { + if strings.HasSuffix(strings.ToLower(bin), ".exe") { + tmp = strings.TrimSuffix(bin, ".exe") + ".tmp." + ts + ".exe" + } + } log.Infof("%s: downloading k0s %s", h, p.Config.Spec.K0s.Version) if h.K0sDownloadURL != "" { @@ -74,9 +82,8 @@ func (p *DownloadK0s) downloadK0s(_ context.Context, h *cluster.Host) error { return err } - if err := h.Execf(`chmod +x "%s"`, tmp, exec.Sudo(h)); err != nil { - log.Warnf("%s: failed to chmod k0s temp binary: %v", h, err.Error()) - } + // Make executable on POSIX; no-op on Windows + _ = h.Configurer.Chmod(h, tmp, "0755", exec.Sudo(h)) h.Metadata.K0sBinaryTempFile = tmp diff --git a/phase/install_binaries.go b/phase/install_binaries.go index 0678074b..3d7e3ec7 100644 --- a/phase/install_binaries.go +++ b/phase/install_binaries.go @@ -51,9 +51,8 @@ func (p *InstallBinaries) DryRun() error { p.Config.Spec.Hosts.Filter(func(h *cluster.Host) bool { return h.Metadata.K0sBinaryTempFile != "" }), func(_ context.Context, h *cluster.Host) error { p.DryMsgf(h, "install k0s %s binary from %s to %s", p.Config.Spec.K0s.Version, h.Metadata.K0sBinaryTempFile, h.Configurer.K0sBinaryPath()) - if err := h.Execf(`chmod +x "%s"`, h.Metadata.K0sBinaryTempFile, exec.Sudo(h)); err != nil { - logrus.Warnf("%s: failed to chmod k0s temp binary for dry-run: %s", h, err.Error()) - } + // Ensure temp binary is executable on POSIX; no-op on Windows + _ = h.Configurer.Chmod(h, h.Metadata.K0sBinaryTempFile, "0755", exec.Sudo(h)) h.Configurer.SetPath("K0sBinaryPath", h.Metadata.K0sBinaryTempFile) h.Metadata.K0sBinaryVersion = p.Config.Spec.K0s.Version return nil diff --git a/phase/upload_k0s.go b/phase/upload_k0s.go index 601ac0f5..3eb42f58 100644 --- a/phase/upload_k0s.go +++ b/phase/upload_k0s.go @@ -1,11 +1,12 @@ package phase import ( - "context" - "fmt" - "os" - "strconv" - "time" + "context" + "fmt" + "os" + "strconv" + "time" + "strings" "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" @@ -56,11 +57,19 @@ func (p *UploadK0s) ShouldRun() bool { // Run the phase func (p *UploadK0s) Run(ctx context.Context) error { - return p.parallelDoUpload(ctx, p.hosts, p.uploadBinary) + return p.parallelDoUpload(ctx, p.hosts, p.uploadBinary) } func (p *UploadK0s) uploadBinary(_ context.Context, h *cluster.Host) error { - tmp := h.Configurer.K0sBinaryPath() + ".tmp." + strconv.Itoa(int(time.Now().UnixNano())) + ts := strconv.Itoa(int(time.Now().UnixNano())) + bin := h.Configurer.K0sBinaryPath() + tmp := bin + ".tmp." + ts + if h.IsConnected() && h.IsWindows() { + // Place the temp marker before the .exe extension + if strings.HasSuffix(strings.ToLower(bin), ".exe") { + tmp = strings.TrimSuffix(bin, ".exe") + ".tmp." + ts + ".exe" + } + } stat, err := os.Stat(h.UploadBinaryPath) if err != nil { @@ -72,12 +81,11 @@ func (p *UploadK0s) uploadBinary(_ context.Context, h *cluster.Host) error { return fmt.Errorf("upload k0s binary: %w", err) } - if err := h.Configurer.Touch(h, tmp, stat.ModTime(), exec.Sudo(h)); err != nil { - return fmt.Errorf("failed to touch %s: %w", tmp, err) - } - if err := h.Execf(`chmod +x "%s"`, tmp, exec.Sudo(h)); err != nil { - log.Warnf("%s: failed to chmod k0s temp binary: %v", h, err.Error()) - } + if err := h.Configurer.Touch(h, tmp, stat.ModTime(), exec.Sudo(h)); err != nil { + return fmt.Errorf("failed to touch %s: %w", tmp, err) + } + // Make executable on POSIX; no-op on Windows + _ = h.Configurer.Chmod(h, tmp, "0755", exec.Sudo(h)) h.Metadata.K0sBinaryTempFile = tmp diff --git a/phase/uploadfiles.go b/phase/uploadfiles.go index db0dc748..39cc9dc0 100644 --- a/phase/uploadfiles.go +++ b/phase/uploadfiles.go @@ -1,15 +1,13 @@ package phase import ( - "context" - "fmt" - "os" - "path" - - "al.essio.dev/pkg/shellescape" - "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" - "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" - "github.com/k0sproject/rig/exec" + "context" + "fmt" + "os" + "path" + "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" + "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" + "github.com/k0sproject/rig/exec" log "github.com/sirupsen/logrus" ) @@ -88,14 +86,14 @@ func (p *UploadFiles) ensureDir(h *cluster.Host, dir, perm, owner string) error return fmt.Errorf("failed to set permissions for directory %s: %w", dir, err) } - if owner != "" { - err = p.Wet(h, fmt.Sprintf("set owner for directory %s to %s", dir, owner), func() error { - return h.Execf(`chown "%s" "%s"`, owner, dir, exec.Sudo(h)) - }) - if err != nil { - return err - } - } + if owner != "" { + err = p.Wet(h, fmt.Sprintf("set owner for directory %s to %s", dir, owner), func() error { + return h.Configurer.Chown(h, dir, owner, exec.Sudo(h)) + }) + if err != nil { + return err + } + } return nil } @@ -136,15 +134,15 @@ func (p *UploadFiles) uploadFile(h *cluster.Host, f *cluster.UploadFile) error { log.Infof("%s: file already exists and hasn't been changed, skipping upload", h) } - if owner != "" { - err := p.Wet(h, fmt.Sprintf("set owner for %s to %s", dest, owner), func() error { - log.Debugf("%s: setting owner %s for %s", h, owner, dest) - return h.Execf(`chown %s %s`, shellescape.Quote(owner), shellescape.Quote(dest), exec.Sudo(h)) - }) - if err != nil { - return err - } - } + if owner != "" { + err := p.Wet(h, fmt.Sprintf("set owner for %s to %s", dest, owner), func() error { + log.Debugf("%s: setting owner %s for %s", h, owner, dest) + return h.Configurer.Chown(h, dest, owner, exec.Sudo(h)) + }) + if err != nil { + return err + } + } err := p.Wet(h, fmt.Sprintf("set permissions for %s to %s", dest, s.PermMode), func() error { log.Debugf("%s: setting permissions %s for %s", h, s.PermMode, dest) return h.Configurer.Chmod(h, dest, s.PermMode, exec.Sudo(h)) @@ -193,15 +191,15 @@ func (p *UploadFiles) uploadURL(h *cluster.Host, f *cluster.UploadFile) error { } } - if owner != "" { - err := p.Wet(h, fmt.Sprintf("set owner for %s to %s", f.DestinationFile, owner), func() error { - log.Debugf("%s: setting owner %s for %s", h, owner, f.DestinationFile) - return h.Execf(`chown %s %s`, shellescape.Quote(owner), shellescape.Quote(f.DestinationFile), exec.Sudo(h)) - }) - if err != nil { - return err - } - } + if owner != "" { + err := p.Wet(h, fmt.Sprintf("set owner for %s to %s", f.DestinationFile, owner), func() error { + log.Debugf("%s: setting owner %s for %s", h, owner, f.DestinationFile) + return h.Configurer.Chown(h, f.DestinationFile, owner, exec.Sudo(h)) + }) + if err != nil { + return err + } + } return nil } diff --git a/phase/validate_hosts.go b/phase/validate_hosts.go index bcc0b888..9732bb6f 100644 --- a/phase/validate_hosts.go +++ b/phase/validate_hosts.go @@ -1,13 +1,13 @@ package phase import ( - "context" - "fmt" - "io/fs" - "path/filepath" - "sort" - "sync" - "time" + "context" + "fmt" + "io/fs" + "path/filepath" + "sort" + "sync" + "time" "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" log "github.com/sirupsen/logrus" @@ -113,31 +113,30 @@ const cleanUpOlderThan = 30 * time.Minute // clean up any k0s.tmp.* files from K0sBinaryPath that are older than 30 minutes and warn if there are any that are newer than that func (p *ValidateHosts) cleanUpOldK0sTmpFiles(_ context.Context, h *cluster.Host) error { - err := fs.WalkDir(h.SudoFsys(), filepath.Join(filepath.Dir(h.Configurer.K0sBinaryPath()), "k0s.tmp.*"), func(path string, d fs.DirEntry, err error) error { - if err != nil { - log.Warnf("failed to walk k0s.tmp.* files in %s: %v", h.Configurer.K0sBinaryPath(), err) - return nil - } - log.Debugf("%s: found k0s binary upload temporary file %s", h, path) - info, err := d.Info() - if err != nil { - log.Warnf("%s: failed to get info for %s: %v", h, path, err) - return nil - } - if time.Since(info.ModTime()) > cleanUpOlderThan { - log.Warnf("%s: cleaning up old k0s binary upload temporary file %s", h, path) - if err := h.Configurer.DeleteFile(h, path); err != nil { - log.Warnf("%s: failed to delete %s: %v", h, path, err) - } - return nil - } - log.Warnf("%s: found k0s binary upload temporary file %s that is newer than %s", h, path, cleanUpOlderThan) - return nil - }) - if err != nil { - log.Warnf("failed to walk k0s.tmp.* files in %s: %v", h.Configurer.K0sBinaryPath(), err) - } - return nil + // Use fs.Glob over the remote FS to find matching temp files + pattern := filepath.Join(filepath.Dir(h.Configurer.K0sBinaryPath()), "k0s.tmp.*") + matches, err := fs.Glob(h.SudoFsys(), pattern) + if err != nil { + log.Warnf("failed to glob k0s.tmp.* files in %s: %v", h.Configurer.K0sBinaryPath(), err) + return nil + } + for _, pth := range matches { + log.Debugf("%s: found k0s binary upload temporary file %s", h, pth) + info, err := fs.Stat(h.SudoFsys(), pth) + if err != nil { + log.Warnf("%s: failed to get info for %s: %v", h, pth, err) + continue + } + if time.Since(info.ModTime()) > cleanUpOlderThan { + log.Warnf("%s: cleaning up old k0s binary upload temporary file %s", h, pth) + if err := h.Configurer.DeleteFile(h, pth); err != nil { + log.Warnf("%s: failed to delete %s: %v", h, pth, err) + } + continue + } + log.Warnf("%s: found k0s binary upload temporary file %s that is newer than %s", h, pth, cleanUpOlderThan) + } + return nil } const maxSkew = 30 * time.Second diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/host.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/host.go index 3f1d8a42..b43b48a3 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/host.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/host.go @@ -1,13 +1,14 @@ package cluster import ( - "fmt" - "net/url" - gos "os" - gopath "path" - "slices" - "strings" - "time" + "fmt" + "net/url" + "io" + gos "os" + gopath "path" + "slices" + "strings" + "time" "al.essio.dev/pkg/shellescape" "github.com/creasty/defaults" @@ -151,9 +152,10 @@ type configurer interface { ServiceScriptPath(os.Host, string) (string, error) ReadFile(os.Host, string) (string, error) FileExist(os.Host, string) bool - Chmod(os.Host, string, string, ...exec.Option) error - DownloadK0s(os.Host, string, *version.Version, string, ...exec.Option) error - DownloadURL(os.Host, string, string, ...exec.Option) error + Chmod(os.Host, string, string, ...exec.Option) error + Chown(os.Host, string, string, ...exec.Option) error + DownloadK0s(os.Host, string, *version.Version, string, ...exec.Option) error + DownloadURL(os.Host, string, string, ...exec.Option) error InstallPackage(os.Host, ...string) error FileContains(os.Host, string, string) bool MoveFile(os.Host, string, string) error @@ -168,10 +170,12 @@ type configurer interface { HTTPStatus(os.Host, string) (int, error) PrivateInterface(os.Host) (string, error) PrivateAddress(os.Host, string, string) (string, error) - TempDir(os.Host) (string, error) - TempFile(os.Host) (string, error) - UpdateServiceEnvironment(os.Host, string, map[string]string) error - CleanupServiceEnvironment(os.Host, string) error + TempDir(os.Host) (string, error) + TempFile(os.Host) (string, error) + ListDir(os.Host, string) ([]string, error) + StreamFile(os.Host, string, io.Writer, ...exec.Option) error + UpdateServiceEnvironment(os.Host, string, map[string]string) error + CleanupServiceEnvironment(os.Host, string) error Stat(os.Host, string, ...exec.Option) (*os.FileInfo, error) Touch(os.Host, string, time.Time, ...exec.Option) error DeleteDir(os.Host, string, ...exec.Option) error @@ -408,14 +412,17 @@ func (h *Host) InstallK0sBinary(path string) error { return fmt.Errorf("k0s binary tempfile not found") } - dir := h.k0sBinaryPathDir() - if err := h.Execf(`install -m 0755 -o root -g root -d "%s"`, dir, exec.Sudo(h)); err != nil { - return fmt.Errorf("create k0s binary dir: %w", err) - } + dir := h.k0sBinaryPathDir() + if err := h.Configurer.MkDir(h, dir, exec.Sudo(h)); err != nil { + return fmt.Errorf("create k0s binary dir: %w", err) + } + // Best-effort permissions on POSIX; no-op on Windows + _ = h.Configurer.Chmod(h, dir, "0755", exec.Sudo(h)) - if err := h.Execf(`install -m 0750 -o root -g root "%s" "%s"`, path, h.Configurer.K0sBinaryPath(), exec.Sudo(h)); err != nil { - return fmt.Errorf("install k0s binary: %w", err) - } + if err := h.Configurer.MoveFile(h, path, h.Configurer.K0sBinaryPath()); err != nil { + return fmt.Errorf("install k0s binary: %w", err) + } + _ = h.Configurer.Chmod(h, h.Configurer.K0sBinaryPath(), "0750", exec.Sudo(h)) if err := h.Configurer.DeleteFile(h, path); err != nil { log.Warnf("%s: failed to delete k0s binary tempfile: %s", h, err)