Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement auto upgrades of wins.exe via the SUC image #260

Merged
merged 3 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions .github/workflows/PR.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,6 @@ permissions:
contents: read

jobs:
# While golanglint-ci is also run in the mage file,
# adding an explicit gha step highlights the syntax errors
# when reviewing PRs
golint:
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- name: golangci-lint
uses: golangci/[email protected]
with:
args: --timeout=10m
version: v1.60

test:
strategy:
fail-fast: false
Expand All @@ -37,12 +24,23 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: 'stable'

- name: Install Dependencies
run: |
go install github.com/magefile/[email protected]
go install github.com/golangci/golangci-lint/cmd/[email protected]

- name: Build
shell: pwsh
run: |
set PSModulePath=&&powershell -command "mage BuildAll"

- name: golangci-lint
uses: golangci/[email protected]
with:
args: --timeout=10m
version: v1.60

- name: Run E2E tests
shell: pwsh
run: |
Expand Down
3 changes: 2 additions & 1 deletion .golangci.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"scripts",
"charts",
"package",
"pkg/powershell/powershell.go"
"pkg/powershell/powershell.go",
"suc/pkg/host/embed.go"
]
}
}
24 changes: 11 additions & 13 deletions magefiles/magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ func Validate() error {
}

func BuildAll() error {
mg.SerialDeps(Build, BuildSUC)
mg.SerialDeps(Build, BuildSUC, Validate)
return nil
}

func Build() error {
mg.Deps(Clean, Dependencies, Validate)
mg.Deps(Clean, Dependencies)
winsOutput := filepath.Join("bin", "wins.exe")

log.Printf("[Build] Building wins version: %s \n", version)
Expand Down Expand Up @@ -175,6 +175,12 @@ func Build() error {

func BuildSUC() error {
log.Printf("[Build] Building wins SUC version: %s \n", version)
// move wins.exe into the suc package so that it can be embedded
err := sh.Copy(filepath.Join("suc/pkg/host/wins.exe"), filepath.Join(artifactOutput, "wins.exe"))
if err != nil {
log.Printf("failed to copy wins.exe to suc/pkg/host")
return err
}
winsSucOutput := filepath.Join("bin", "wins-suc.exe")
if err := g.Build(flags, "suc/main.go", winsSucOutput); err != nil {
return err
Expand All @@ -198,7 +204,7 @@ func Test() error {
// Integration target must be run on a wins system
// with Containers feature / docker installed
func Integration() error {
mg.Deps(Build)
mg.Deps(BuildAll)
log.Printf("[Integration] Starting Integration Test for wins version %s \n", version)

// make sure the docker files have access to the exe
Expand All @@ -221,20 +227,12 @@ func Integration() error {
}

func TestAll() error {
mg.Deps(BuildAll)
// don't run Test and Integration in mg.Deps
// as deps run in an unordered asynchronous fashion
if err := Test(); err != nil {
return err
}
if err := Integration(); err != nil {
return err
}
mg.SerialDeps(Test, Integration)
return nil
}

func CI() {
mg.Deps(Test)
mg.Deps(TestAll)
}

func flags(version string, commit string) string {
Expand Down
10 changes: 8 additions & 2 deletions suc/pkg/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import (
"errors"
"fmt"

"github.com/rancher/wins/suc/pkg/host"
"github.com/rancher/wins/suc/pkg/rancher"
"github.com/rancher/wins/suc/pkg/service"
"github.com/rancher/wins/suc/pkg/service/config"
"github.com/rancher/wins/suc/pkg/service/state"
"github.com/rancher/wins/suc/pkg/state"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
Expand Down Expand Up @@ -49,7 +50,12 @@ func Run(_ *cli.Context) error {
errs = append(errs, err)
}

if restartServiceDueToConfigChange {
restartServiceDueToBinaryUpgrade, err := host.UpgradeRancherWinsBinary()
if err != nil {
return fmt.Errorf("failed to upgrade wins.exe: %w", err)
}

if restartServiceDueToConfigChange || restartServiceDueToBinaryUpgrade {
err = service.RefreshWinsService()
if err != nil {
errs = append(errs, fmt.Errorf("error encountered while attempting to restart rancher-wins: %w", err))
Expand Down
162 changes: 162 additions & 0 deletions suc/pkg/host/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package host

import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"

"github.com/sirupsen/logrus"
)

const (
defaultWinsPath = "c:\\Windows\\wins.exe"
defaultWinsUsrLocalBinPath = "c:\\usr\\local\\bin\\wins.exe"
defaultConfigDir = "c:\\etc\\rancher\\wins"
fileOperationAttempts = 5
fileOperationAttemptDelayInSeconds = 5

// skipBinaryUpgradeEnvVar prevents the suc image from attempting to upgrade the wins binary.
// This is primarily used in CI, to allow for test cases to run without having to completely
// install rancher-wins.
skipBinaryUpgradeEnvVar = "CATTLE_WINS_SKIP_BINARY_UPGRADE"
)

// getRancherWinsVersionFromBinary executes the wins.exe binary located at 'path' and passes the '--version'
// flag. The release version or commit hash is returned. If the binary returns unexpected output,
// was built with a dirty commit, or does not exist, an error will be returned.
func getRancherWinsVersionFromBinary(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("must specify a path")
jakefhyde marked this conversation as resolved.
Show resolved Hide resolved
}

_, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("provided path (%s) does not exist", path)
jakefhyde marked this conversation as resolved.
Show resolved Hide resolved
}
return "", fmt.Errorf("encoutered error stat'ing '%s': %w", path, err)
}

out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
logrus.Errorf("could not invoke '%s --version' to determine installed wins.exe version: %v", path, err)
return "", fmt.Errorf("failed to invoke '%s --version': %w", path, err)
}

logrus.Debugf("'%s --version' output: %s", path, string(out))
return parseWinsVersion(string(out))
}

func confirmWinsBinaryIsInstalled() (bool, error) {
_, err := os.Stat(defaultWinsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("could not determine if installed wins binary exists: %v", err)
}
return true, nil
}

func confirmWinsBinaryVersion(desiredVersion string, path string) error {
installedVersion, err := getRancherWinsVersionFromBinary(path)
if err != nil {
return fmt.Errorf("failed to confirm '%s' version: %w", path, err)
}

if installedVersion == desiredVersion {
logrus.Debugf("'%s' returned expected version (%s)", path, desiredVersion)
return nil
}

return fmt.Errorf("'%s' version ('%s') did not match desired version ('%s')", path, installedVersion, desiredVersion)
}

func parseWinsVersion(winsOutput string) (string, error) {
// Expected output format is 'rancher-wins version v0.x.y[-rc.z]'"
// A dirty binary will return 'rancher-wins version COMMIT-dirty'
// A non-tagged version will return 'rancher-wins version COMMIT'
s := strings.Split(winsOutput, " ")
if len(s) != 3 {
return "", fmt.Errorf("'wins.exe --version' did not return expected output length ('%v' was returned)", s)
}

verString := strings.Trim(s[2], "\n")
// We should error out if the binary we're working with is dirty, but
// if it's simply untagged we should proceed with the upgrade.
if strings.Contains(verString, "dirty") {
return "", fmt.Errorf("wins.exe binary returned a dirty version (%s)", verString)
}

return verString, nil
}

// copyFile opens the file located at 'source' and creates a new file at 'destination'
// with the same contents. In the event that the 'source' or 'destination' file is being used,
// copyFile will reattempt the operation 5 times over the course of 25 seconds. If the file still cannot
// be moved, an error will be returned. This behavior is beneficial when handling binaries
// that are referenced by services, as the underlying binary used by a service may continue to run
// for a brief time after the service has processed the stop signal.
//
// Note that permission bits on Windows do not function in the same
// way as Linux, the owner bit is always copied to all other bits. The caller of copyFile must
// ensure that the destination is covered by appropriate access control lists.
func copyFile(source, dest string) error {
var err error
var b []byte

_, err = os.Stat(source)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("specified source file '%s' cannot be copied as it does not exist: %w", source, err)
}
return fmt.Errorf("failed to stat source file '%s': %w", source, err)
}

for i := 0; i < fileOperationAttempts; i++ {
b, err = os.ReadFile(source)
if err != nil {
if strings.Contains(err.Error(), "because it is being used by another process") {
logrus.Debugf("file copy attempt failed as the source file is in use, waiting %d seconds before reattempting", fileOperationAttemptDelayInSeconds)
time.Sleep(fileOperationAttemptDelayInSeconds * time.Second)
continue
}
return fmt.Errorf("failed to read from '%s': %w", source, err)
}

err = os.WriteFile(dest, b, os.ModePerm)
if err != nil {
if strings.Contains(err.Error(), "because it is being used by another process") {
logrus.Debugf("file copy attempt failed as the destination file is in use, waiting %d seconds before reattempting", fileOperationAttemptDelayInSeconds)
time.Sleep(fileOperationAttemptDelayInSeconds * time.Second)
continue
}
return fmt.Errorf("failed to write to '%s': %w", dest, err)
}
}

if err != nil {
return fmt.Errorf("failed to copy '%s' to '%s': %w", source, dest, err)
}

return nil
}

func getWinsConfigDir() string {
customPath := os.Getenv("CATTLE_AGENT_CONFIG_DIR")
if customPath != "" {
return customPath
}
return defaultConfigDir
}

func getWinsUsrLocalBinBinary() string {
customPath := os.Getenv("CATTLE_AGENT_BIN_PREFIX")
if customPath != "" {
return fmt.Sprintf("%s\\bin\\wins.exe", customPath)
}
return defaultWinsUsrLocalBinPath
}
63 changes: 63 additions & 0 deletions suc/pkg/host/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package host

import "testing"

func TestParseWinsVersion(t *testing.T) {
type test struct {
name string
winsOutput string
expectedVersion string
errExpected bool
}

tests := []test{
{
name: "Released version",
winsOutput: "rancher-wins version v0.4.20",
expectedVersion: "v0.4.20",
errExpected: false,
},
{
name: "RC version",
winsOutput: "rancher-wins version v0.4.20-rc.1",
expectedVersion: "v0.4.20-rc.1",
errExpected: false,
},
{
name: "Dirty Commit",
winsOutput: "rancher-wins version 06685df-dirty",
expectedVersion: "",
errExpected: true,
},
{
name: "Unreleased Clean Commit",
winsOutput: "rancher-wins version 06685df",
expectedVersion: "06685df",
errExpected: false,
},
{
name: "Empty output",
winsOutput: "",
expectedVersion: "",
errExpected: true,
},
{
name: "unexpected format output",
winsOutput: "rancher-wins version",
expectedVersion: "",
errExpected: true,
},
}

for _, tst := range tests {
t.Run(tst.name, func(t *testing.T) {
version, err := parseWinsVersion(tst.winsOutput)
if err != nil && !tst.errExpected {
t.Fatalf("encountered unexpected errror, wins output: '%s', returned version: '%s': %v", tst.winsOutput, version, err)
}
if version != tst.expectedVersion {
t.Fatalf("encountered unexpected version, wins output: '%s', returned version: '%s', expected version: '%s'", tst.winsOutput, version, tst.expectedVersion)
}
})
}
}
6 changes: 6 additions & 0 deletions suc/pkg/host/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package host

import _ "embed"

//go:embed wins.exe
var winsBinary []byte
Loading