Skip to content

Commit

Permalink
Implement auto upgrades of wins.exe via the SUC image (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
HarrisonWAffel authored Jan 7, 2025
1 parent 06685df commit e1a4ef2
Show file tree
Hide file tree
Showing 14 changed files with 524 additions and 45 deletions.
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")
}

_, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("provided path (%s) does not exist", path)
}
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

0 comments on commit e1a4ef2

Please sign in to comment.