From 917aa9932c1744d67d2e98b404b82f1b5fc9ec5c Mon Sep 17 00:00:00 2001 From: Paulo Janotti Date: Wed, 18 Dec 2024 09:08:12 -0800 Subject: [PATCH] Add basic MSI tests to CI (#753) * Add github workflow for MSI tests * Find MSI location (temporary) * Fix path for upload-artifact for MSI * Add golang tests * Fix path to MSI * Temporary step to check MSI location * Fix MSI location * Debug powershell cmds * Testing powershell cmds * More pwsh debugging * Fix path (still with debugging) * Cleanup CI debug code * Remove debug step * Better name for action * remove "${{ }}" since it was not needed Co-authored-by: Moritz Wiesinger --------- Co-authored-by: Moritz Wiesinger --- .github/workflows/base-ci-goreleaser.yaml | 8 + .github/workflows/ci-goreleaser-contrib.yaml | 8 + .github/workflows/ci-goreleaser-core.yaml | 9 +- .github/workflows/msi-tests.yaml | 43 ++++ tests/msi/go.mod | 14 ++ tests/msi/go.sum | 12 ++ tests/msi/msi_test.go | 197 +++++++++++++++++++ 7 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/msi-tests.yaml create mode 100644 tests/msi/go.mod create mode 100644 tests/msi/go.sum create mode 100644 tests/msi/msi_test.go diff --git a/.github/workflows/base-ci-goreleaser.yaml b/.github/workflows/base-ci-goreleaser.yaml index 19932f02..bade00b9 100644 --- a/.github/workflows/base-ci-goreleaser.yaml +++ b/.github/workflows/base-ci-goreleaser.yaml @@ -87,3 +87,11 @@ jobs: name: linux-packages path: distributions/${{ inputs.distribution }}/dist/linux_amd64_v1/* if-no-files-found: error + + - name: Upload MSI packages + if: matrix.GOOS == 'windows' && matrix.GOARCH == 'amd64' + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: msi-packages + path: distributions/${{ inputs.distribution }}/dist/windows_amd64_v1/**/*.msi + if-no-files-found: error diff --git a/.github/workflows/ci-goreleaser-contrib.yaml b/.github/workflows/ci-goreleaser-contrib.yaml index fed5f85d..71f731b4 100644 --- a/.github/workflows/ci-goreleaser-contrib.yaml +++ b/.github/workflows/ci-goreleaser-contrib.yaml @@ -40,3 +40,11 @@ jobs: with: distribution: otelcol-contrib type: '[ "deb", "rpm" ]' + + msi-tests: + name: MSI tests + needs: check-goreleaser + uses: ./.github/workflows/msi-tests.yaml + with: + distribution: otelcol-contrib + type: '[ "msi" ]' diff --git a/.github/workflows/ci-goreleaser-core.yaml b/.github/workflows/ci-goreleaser-core.yaml index 80a116a8..4e761948 100644 --- a/.github/workflows/ci-goreleaser-core.yaml +++ b/.github/workflows/ci-goreleaser-core.yaml @@ -23,7 +23,6 @@ on: - "go.mod" - "go.sum" - jobs: check-goreleaser: name: Continuous Integration - Core - GoReleaser @@ -41,3 +40,11 @@ jobs: with: distribution: otelcol type: '[ "deb", "rpm" ]' + + msi-tests: + name: MSI tests + needs: check-goreleaser + uses: ./.github/workflows/msi-tests.yaml + with: + distribution: otelcol + type: '[ "msi" ]' diff --git a/.github/workflows/msi-tests.yaml b/.github/workflows/msi-tests.yaml new file mode 100644 index 00000000..a0507e76 --- /dev/null +++ b/.github/workflows/msi-tests.yaml @@ -0,0 +1,43 @@ +name: MSI Tests + +on: + workflow_call: + inputs: + type: + required: true + type: string + distribution: + required: true + type: string + +jobs: + msi-tests: + name: MSI Tests + runs-on: otel-windows-latest-8-cores + strategy: + matrix: + type: ${{ fromJSON(inputs.type) }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Download built artifacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: msi-packages + + - name: Set required environment variables for MSI tests + run: | + $ErrorActionPreference = 'Stop' + $alt_config_path = Resolve-Path .\distributions\${{ inputs.distribution }}\config.yaml + Test-Path $alt_config_path + $msi_path = Resolve-Path .\msi\*\*.msi + Test-Path $msi_path + "MSI_TEST_ALTERNATE_CONFIG_FILE=$alt_config_path" | Out-File -FilePath $env:GITHUB_ENV -Append + "MSI_TEST_COLLECTOR_PATH=$msi_path" | Out-File -FilePath $env:GITHUB_ENV -Append + "MSI_TEST_COLLECTOR_SERVICE_NAME=${{ inputs.distribution }}" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Run the MSI tests + working-directory: tests/msi + run: | + go test -timeout 15m -v ./... diff --git a/tests/msi/go.mod b/tests/msi/go.mod new file mode 100644 index 00000000..b5477649 --- /dev/null +++ b/tests/msi/go.mod @@ -0,0 +1,14 @@ +module msi + +go 1.23 + +require ( + github.com/stretchr/testify v1.10.0 + golang.org/x/sys v0.27.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tests/msi/go.sum b/tests/msi/go.sum new file mode 100644 index 00000000..ba4b652e --- /dev/null +++ b/tests/msi/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/msi/msi_test.go b/tests/msi/msi_test.go new file mode 100644 index 00000000..15166c63 --- /dev/null +++ b/tests/msi/msi_test.go @@ -0,0 +1,197 @@ +// Copyright The OpenTelemetry Authors +// +// 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. + +//go:build windows + +package msi + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +// Test structure for MSI installation tests +type msiTest struct { + name string + collectorServiceArgs string + skipSvcStop bool +} + +func TestMSI(t *testing.T) { + msiInstallerPath := getInstallerPath(t) + + tests := []msiTest{ + { + name: "default", + }, + { + name: "custom", + collectorServiceArgs: "--config " + quotedIfRequired(getAlternateConfigFile(t)), + skipSvcStop: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runMsiTest(t, tt, msiInstallerPath) + }) + } +} + +func runMsiTest(t *testing.T, test msiTest, msiInstallerPath string) { + // Build the MSI installation arguments and include the MSI properties map. + installLogFile := filepath.Join(os.TempDir(), "install.log") + args := []string{"/i", msiInstallerPath, "/qn", "/l*v", installLogFile} + + serviceArgs := quotedIfRequired(test.collectorServiceArgs) + if test.collectorServiceArgs != "" { + args = append(args, "COLLECTOR_SVC_ARGS="+serviceArgs) + } + + // Run the MSI installer + installCmd := exec.Command("msiexec") + + // msiexec is one of the noticeable exceptions about how to format the parameters, + // see https://pkg.go.dev/os/exec#Command, so we need to join the args manually. + cmdLine := strings.Join(args, " ") + installCmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: "msiexec " + cmdLine} + err := installCmd.Run() + if err != nil { + logText, _ := os.ReadFile(installLogFile) + t.Log(string(logText)) + } + t.Logf("Install command: %s", installCmd.SysProcAttr.CmdLine) + require.NoError(t, err, "Failed to install the MSI: %v\nArgs: %v", err, args) + + defer func() { + // Uninstall the MSI + uninstallCmd := exec.Command("msiexec") + uninstallCmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: "msiexec /x " + msiInstallerPath + " /qn"} + err := uninstallCmd.Run() + t.Logf("Uninstall command: %s", uninstallCmd.SysProcAttr.CmdLine) + require.NoError(t, err, "Failed to uninstall the MSI: %v", err) + }() + + // Verify the service + scm, err := mgr.Connect() + require.NoError(t, err) + defer scm.Disconnect() + + collectorSvcName := getServiceName(t) + service, err := scm.OpenService(collectorSvcName) + require.NoError(t, err) + defer service.Close() + + // Wait for the service to reach the running state + require.Eventually(t, func() bool { + status, err := service.Query() + require.NoError(t, err) + return status.State == svc.Running + }, 10*time.Second, 500*time.Millisecond, "Failed to start the service") + + if !test.skipSvcStop { + defer func() { + _, err = service.Control(svc.Stop) + require.NoError(t, err) + + require.Eventually(t, func() bool { + status, err := service.Query() + require.NoError(t, err) + return status.State == svc.Stopped + }, 10*time.Second, 500*time.Millisecond, "Failed to stop the service") + }() + } + + assertServiceCommand(t, collectorSvcName, serviceArgs) +} + +func assertServiceCommand(t *testing.T, serviceName, collectorServiceArgs string) { + // Verify the service command + actualCommand := getServiceCommand(t, serviceName) + expectedCommand := expectedServiceCommand(t, serviceName, collectorServiceArgs) + assert.Equal(t, expectedCommand, actualCommand) +} + +func getServiceCommand(t *testing.T, serviceName string) string { + scm, err := mgr.Connect() + require.NoError(t, err) + defer scm.Disconnect() + + service, err := scm.OpenService(serviceName) + require.NoError(t, err) + defer service.Close() + + config, err := service.Config() + require.NoError(t, err) + + return config.BinaryPathName +} + +func expectedServiceCommand(t *testing.T, serviceName, collectorServiceArgs string) string { + programFilesDir := os.Getenv("PROGRAMFILES") + require.NotEmpty(t, programFilesDir, "PROGRAMFILES environment variable is not set") + + collectorDir := filepath.Join(programFilesDir, "OpenTelemetry Collector") + collectorExe := filepath.Join(collectorDir, serviceName) + ".exe" + + if collectorServiceArgs == "" { + collectorServiceArgs = "--config " + quotedIfRequired(filepath.Join(collectorDir, "config.yaml")) + } else { + // Remove any quotation added for the msiexec command line + collectorServiceArgs = strings.Trim(collectorServiceArgs, "\"") + collectorServiceArgs = strings.ReplaceAll(collectorServiceArgs, "\"\"", "\"") + } + + return quotedIfRequired(collectorExe) + " " + collectorServiceArgs +} + +func getServiceName(t *testing.T) string { + serviceName := os.Getenv("MSI_TEST_COLLECTOR_SERVICE_NAME") + require.NotEmpty(t, serviceName, "MSI_TEST_COLLECTOR_SERVICE_NAME environment variable is not set") + return serviceName +} + +func getInstallerPath(t *testing.T) string { + msiInstallerPath := os.Getenv("MSI_TEST_COLLECTOR_PATH") + require.NotEmpty(t, msiInstallerPath, "MSI_TEST_COLLECTOR_PATH environment variable is not set") + _, err := os.Stat(msiInstallerPath) + require.NoError(t, err) + return msiInstallerPath +} + +func getAlternateConfigFile(t *testing.T) string { + alternateConfigFile := os.Getenv("MSI_TEST_ALTERNATE_CONFIG_FILE") + require.NotEmpty(t, alternateConfigFile, "MSI_TEST_ALTERNATE_CONFIG_FILE environment variable is not set") + _, err := os.Stat(alternateConfigFile) + require.NoError(t, err) + return alternateConfigFile +} + +func quotedIfRequired(s string) string { + if strings.Contains(s, "\"") || strings.Contains(s, " ") { + s = strings.ReplaceAll(s, "\"", "\"\"") + return "\"" + s + "\"" + } + return s +}