From f38a15e91427633099cdf04f3e5567f8227a0f65 Mon Sep 17 00:00:00 2001 From: Brandon Johnson Date: Wed, 13 Jul 2022 13:17:18 -0400 Subject: [PATCH] feat: Add ability to install unpacked artifacts in updater (#562) * start artifact install logic * fix uninstall service step * add tests for windows service manager * remove kardiano/service dependency * check filepath with spaces * more tests, hook up to main * naming * add licenses * gosec fixes * linux gosec + some lint issues * linter * fix formatting of windows service test * actually fix formatting * guard linux/win service tests behind tag * run tests as sudo on linux * fix inverted conditional * split updater integration tests into separate target * refactor package for better encapsulation * update darwin service to load/unload for start/stop * fix installDir for windows after rename * test replaceInstallDir * add license to service_test.go * fix make target phony * add some comments * add start of readme * add a (very basic) readme * use switch instead of multiple ifs * Add comments to moveFiles * fix failing darwin test --- .github/workflows/tests.yml | 6 + Makefile | 9 +- updater/.gitignore | 4 + updater/README.md | 5 + updater/cmd/updater/main.go | 9 + updater/go.mod | 3 + updater/go.sum | 5 + updater/internal/install/install.go | 172 +++++++ updater/internal/install/install_test.go | 189 +++++++ updater/internal/install/mocks/service.go | 81 +++ updater/internal/install/service.go | 43 ++ updater/internal/install/service_darwin.go | 113 +++++ .../internal/install/service_darwin_test.go | 205 ++++++++ updater/internal/install/service_linux.go | 134 +++++ .../internal/install/service_linux_test.go | 214 ++++++++ updater/internal/install/service_test.go | 54 ++ updater/internal/install/service_windows.go | 300 +++++++++++ .../internal/install/service_windows_test.go | 466 ++++++++++++++++++ .../install/testdata/darwin-service.plist | 15 + .../testdata/example-install/config.yaml | 1 + .../testdata/example-install/logging.yaml | 1 + .../testdata/example-install/manager.yaml | 1 + .../test-folder/another-test.txt | 1 + .../install/testdata/example-install/test.txt | 1 + .../install/testdata/linux-service.service | 10 + .../install/testdata/test-windows-service.go | 55 +++ .../install/testdata/windows-service.json | 10 + 27 files changed, 2106 insertions(+), 1 deletion(-) create mode 100644 updater/.gitignore create mode 100644 updater/README.md create mode 100644 updater/internal/install/install.go create mode 100644 updater/internal/install/install_test.go create mode 100644 updater/internal/install/mocks/service.go create mode 100644 updater/internal/install/service.go create mode 100644 updater/internal/install/service_darwin.go create mode 100644 updater/internal/install/service_darwin_test.go create mode 100644 updater/internal/install/service_linux.go create mode 100644 updater/internal/install/service_linux_test.go create mode 100644 updater/internal/install/service_test.go create mode 100644 updater/internal/install/service_windows.go create mode 100644 updater/internal/install/service_windows_test.go create mode 100644 updater/internal/install/testdata/darwin-service.plist create mode 100644 updater/internal/install/testdata/example-install/config.yaml create mode 100644 updater/internal/install/testdata/example-install/logging.yaml create mode 100644 updater/internal/install/testdata/example-install/manager.yaml create mode 100644 updater/internal/install/testdata/example-install/test-folder/another-test.txt create mode 100644 updater/internal/install/testdata/example-install/test.txt create mode 100644 updater/internal/install/testdata/linux-service.service create mode 100644 updater/internal/install/testdata/test-windows-service.go create mode 100644 updater/internal/install/testdata/windows-service.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 419d31a72..cd3af13a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,3 +51,9 @@ jobs: ${{ runner.os }}-go- - name: Run Tests run: make test + - name: Run Updater Integration Tests (non-linux) + if: matrix.os != 'ubuntu-20.04' + run: make test-updater-integration + - name: Run Updater Integration Tests (linux) + if: matrix.os == 'ubuntu-20.04' + run: sudo make test-updater-integration diff --git a/Makefile b/Makefile index 2cde3f598..2ade0f527 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,8 @@ OUTDIR=./dist GOOS ?= $(shell go env GOOS) GOARCH ?= $(shell go env GOARCH) +INTEGRATION_TEST_ARGS?=-tags integration + ifeq ($(GOOS), windows) EXT?=.exe else @@ -109,6 +111,10 @@ test-with-cover: $(MAKE) for-all CMD="go test -coverprofile=cover.out ./..." $(MAKE) for-all CMD="go tool cover -html=cover.out -o cover.html" +.PHONY: test-updater-integration +test-updater-integration: + cd updater; go test $(INTEGRATION_TEST_ARGS) -race ./... + .PHONY: bench bench: $(MAKE) for-all CMD="go test -benchmem -run=^$$ -bench ^* ./..." @@ -128,7 +134,8 @@ tidy: .PHONY: gosec gosec: gosec -exclude-dir updater ./... - cd updater; gosec ./... +# exclude the testdata dir; it contains a go program for testing. + cd updater; gosec -exclude-dir internal/install/testdata ./... # This target performs all checks that CI will do (excluding the build itself) .PHONY: ci-checks diff --git a/updater/.gitignore b/updater/.gitignore new file mode 100644 index 000000000..b89285960 --- /dev/null +++ b/updater/.gitignore @@ -0,0 +1,4 @@ +# logging.yaml and manager.yaml are ignored in the base module, but +# they are important in this module for test data. +!logging.yaml +!manager.yaml diff --git a/updater/README.md b/updater/README.md new file mode 100644 index 000000000..6477f661f --- /dev/null +++ b/updater/README.md @@ -0,0 +1,5 @@ +# observIQ Distro for OpenTelemetry Updater + +The updater is a separate binary that runs as a separate process to update collector artifacts (including the collector itself) when managed by [BindPlane OP](https://github.com/observIQ/bindplane-op). + +Because the updater edits service configurations, it needs elevated privileges to run (root on Linux + macOS, administrative privileges on Windows). diff --git a/updater/cmd/updater/main.go b/updater/cmd/updater/main.go index 3da2cca61..be210a81b 100644 --- a/updater/cmd/updater/main.go +++ b/updater/cmd/updater/main.go @@ -20,6 +20,7 @@ import ( "os" "github.com/observiq/observiq-otel-collector/updater/internal/download" + "github.com/observiq/observiq-otel-collector/updater/internal/install" "github.com/observiq/observiq-otel-collector/updater/internal/version" "github.com/spf13/pflag" ) @@ -61,4 +62,12 @@ func main() { log.Fatalf("Failed to download and verify update: %s", err) } + installer, err := install.NewInstaller(*tmpDir) + if err != nil { + log.Fatalf("Failed to create installer: %s", err) + } + + if err := installer.Install(); err != nil { + log.Fatalf("Failed to install: %s", err) + } } diff --git a/updater/go.mod b/updater/go.mod index 1f8572751..f4224ba1a 100644 --- a/updater/go.mod +++ b/updater/go.mod @@ -3,9 +3,11 @@ module github.com/observiq/observiq-otel-collector/updater go 1.17 require ( + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mholt/archiver/v3 v3.5.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.2 + golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 ) require ( @@ -18,6 +20,7 @@ require ( github.com/nwaples/rardecode v1.1.0 // indirect github.com/pierrec/lz4/v4 v4.1.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.1.0 // indirect github.com/ulikunitz/xz v0.5.9 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/updater/go.sum b/updater/go.sum index f24906262..357785180 100644 --- a/updater/go.sum +++ b/updater/go.sum @@ -9,6 +9,8 @@ github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdf github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw= github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.11.4 h1:kz40R/YWls3iqT9zX9AHN3WoVsrAWVyui5sxuLqiXqU= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= @@ -25,6 +27,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= @@ -33,6 +36,8 @@ github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 h1:9UQO31fZ+0aKQOFldThf7BKPMJTiBfWycGh/u3UoO88= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= diff --git a/updater/internal/install/install.go b/updater/internal/install/install.go new file mode 100644 index 000000000..2f129e931 --- /dev/null +++ b/updater/internal/install/install.go @@ -0,0 +1,172 @@ +// Copyright observIQ, Inc. +// +// 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 install + +import ( + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" +) + +// Installer allows you to install files from latestDir into installDir, +// as well as update the service configuration using the "Install" method. +type Installer struct { + latestDir string + installDir string + svc Service +} + +// NewInstaller returns a new instance of an Installer. +func NewInstaller(tempDir string) (*Installer, error) { + latestDir := filepath.Join(tempDir, "latest") + installDirPath, err := installDir() + if err != nil { + return nil, fmt.Errorf("failed to determine install dir: %w", err) + } + + return &Installer{ + latestDir: latestDir, + svc: newService(latestDir), + installDir: installDirPath, + }, nil +} + +// Install installs the unpacked artifacts in latestDirPath to installDirPath, +// as well as installing the new service file using the provided Service interface +func (i Installer) Install() error { + // Stop service + if err := i.svc.Stop(); err != nil { + return fmt.Errorf("failed to stop service: %w", err) + } + + // install files that go to installDirPath to their correct location, + // excluding any config files (logging.yaml, config.yaml, manager.yaml) + if err := moveFiles(i.latestDir, i.installDir); err != nil { + return fmt.Errorf("failed to install new files: %w", err) + } + + // Uninstall previous service + if err := i.svc.Uninstall(); err != nil { + return fmt.Errorf("failed to uninstall service: %w", err) + } + + // Install new service + if err := i.svc.Install(); err != nil { + return fmt.Errorf("failed to install service: %w", err) + } + + // Start service + if err := i.svc.Start(); err != nil { + return fmt.Errorf("failed to start service: %w", err) + } + + return nil +} + +// moveFiles moves the file tree rooted at latestDirPath to installDirPath, +// skipping configuration files +func moveFiles(latestDirPath, installDirPath string) error { + err := filepath.WalkDir(latestDirPath, func(path string, d fs.DirEntry, err error) error { + switch { + case err != nil: + // if there was an error walking the directory, we want to bail out. + return err + case d.IsDir(): + // Skip directories, we'll create them when we get a file in the directory. + return nil + case skipFile(path): + // Found a config file that we should skip copying. + return nil + } + + cleanPath := filepath.Clean(path) + + // We want the path relative to the directory we are walking in order to calculate where the file should be + // mirrored in the destination directory. + relPath, err := filepath.Rel(latestDirPath, cleanPath) + if err != nil { + return err + } + + // use the relative path to get the outPath (where we should write the file), and + // to get the out directory (which we will create if it does not exist). + outPath := filepath.Clean(filepath.Join(installDirPath, relPath)) + outDir := filepath.Dir(outPath) + + if err := os.MkdirAll(outDir, 0750); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + + // Open the output file, creating it if it does not exist and truncating it. + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open output file: %w", err) + } + defer func() { + err := outFile.Close() + if err != nil { + log.Default().Printf("installFiles: Failed to close output file: %s", err) + } + }() + + // Open the input file for reading. + inFile, err := os.Open(cleanPath) + if err != nil { + return fmt.Errorf("failed to open input file: %w", err) + } + defer func() { + err := inFile.Close() + if err != nil { + log.Default().Printf("installFiles: Failed to close input file: %s", err) + } + }() + + // Copy the input file to the output file. + if _, err := io.Copy(outFile, inFile); err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk latest dir: %w", err) + } + + return nil +} + +// skipFile returns true if the given path is a special config file. +// These files should not be overwritten. +func skipFile(path string) bool { + var configFiles = []string{ + "config.yaml", + "logging.yaml", + "manager.yaml", + } + + fileName := filepath.Base(path) + + for _, f := range configFiles { + if fileName == f { + return true + } + } + + return false +} diff --git a/updater/internal/install/install_test.go b/updater/internal/install/install_test.go new file mode 100644 index 000000000..b13a43c57 --- /dev/null +++ b/updater/internal/install/install_test.go @@ -0,0 +1,189 @@ +// Copyright observIQ, Inc. +// +// 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 install + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/observiq/observiq-otel-collector/updater/internal/install/mocks" + "github.com/stretchr/testify/require" +) + +func TestInstallArtifacts(t *testing.T) { + t.Run("Installs artifacts correctly", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "example-install"), + installDir: outDir, + svc: svc, + } + + outDirConfig := filepath.Join(outDir, "config.yaml") + outDirLogging := filepath.Join(outDir, "logging.yaml") + outDirManager := filepath.Join(outDir, "manager.yaml") + + err := os.WriteFile(outDirConfig, []byte("# The original config file"), 0600) + require.NoError(t, err) + err = os.WriteFile(outDirLogging, []byte("# The original logging file"), 0600) + require.NoError(t, err) + err = os.WriteFile(outDirManager, []byte("# The original manager file"), 0600) + require.NoError(t, err) + + svc.On("Stop").Once().Return(nil) + svc.On("Uninstall").Once().Return(nil) + svc.On("Install").Once().Return(nil) + svc.On("Start").Once().Return(nil) + + err = installer.Install() + require.NoError(t, err) + + contentsEqual(t, outDirConfig, "# The original config file") + contentsEqual(t, outDirManager, "# The original manager file") + contentsEqual(t, outDirLogging, "# The original logging file") + + require.FileExists(t, filepath.Join(outDir, "test.txt")) + require.DirExists(t, filepath.Join(outDir, "test-folder")) + require.FileExists(t, filepath.Join(outDir, "test-folder", "another-test.txt")) + + contentsEqual(t, filepath.Join(outDir, "test.txt"), "This is a test file\n") + contentsEqual(t, filepath.Join(outDir, "test-folder", "another-test.txt"), "This is a nested text file\n") + }) + + t.Run("Stop fails", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "example-install"), + installDir: outDir, + svc: svc, + } + + svc.On("Stop").Once().Return(errors.New("stop failed")) + + err := installer.Install() + require.ErrorContains(t, err, "failed to stop service") + }) + + t.Run("Uninstall fails", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "example-install"), + installDir: outDir, + svc: svc, + } + + svc.On("Stop").Once().Return(nil) + svc.On("Uninstall").Once().Return(errors.New("uninstall failed")) + + err := installer.Install() + require.ErrorContains(t, err, "failed to uninstall service") + }) + + t.Run("Install fails", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "example-install"), + installDir: outDir, + svc: svc, + } + + svc.On("Stop").Once().Return(nil) + svc.On("Uninstall").Once().Return(nil) + svc.On("Install").Once().Return(errors.New("install failed")) + + err := installer.Install() + require.ErrorContains(t, err, "failed to install service") + }) + + t.Run("Start fails", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "example-install"), + installDir: outDir, + svc: svc, + } + + svc.On("Stop").Once().Return(nil) + svc.On("Uninstall").Once().Return(nil) + svc.On("Install").Once().Return(nil) + svc.On("Start").Once().Return(errors.New("start failed")) + + err := installer.Install() + require.ErrorContains(t, err, "failed to start service") + }) + + t.Run("Latest dir does not exist", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "non-existent-dir"), + installDir: outDir, + svc: svc, + } + + svc.On("Stop").Once().Return(nil) + + err := installer.Install() + require.ErrorContains(t, err, "failed to install new files") + }) + + t.Run("An artifact exists already as a folder", func(t *testing.T) { + outDir := t.TempDir() + svc := mocks.NewService(t) + installer := &Installer{ + latestDir: filepath.Join("testdata", "example-install"), + installDir: outDir, + svc: svc, + } + + outDirConfig := filepath.Join(outDir, "config.yaml") + outDirLogging := filepath.Join(outDir, "logging.yaml") + outDirManager := filepath.Join(outDir, "manager.yaml") + + err := os.WriteFile(outDirConfig, []byte("# The original config file"), 0600) + require.NoError(t, err) + err = os.WriteFile(outDirLogging, []byte("# The original logging file"), 0600) + require.NoError(t, err) + err = os.WriteFile(outDirManager, []byte("# The original manager file"), 0600) + require.NoError(t, err) + + err = os.Mkdir(filepath.Join(outDir, "test.txt"), 0750) + require.NoError(t, err) + + svc.On("Stop").Once().Return(nil) + + err = installer.Install() + require.ErrorContains(t, err, "failed to install new files") + }) +} + +func contentsEqual(t *testing.T, path, expectedContents string) { + t.Helper() + + contents, err := os.ReadFile(path) + require.NoError(t, err) + + // Replace \r\n with \n to normalize for windows tests. + contents = bytes.ReplaceAll(contents, []byte("\r\n"), []byte("\n")) + require.Equal(t, []byte(expectedContents), contents) +} diff --git a/updater/internal/install/mocks/service.go b/updater/internal/install/mocks/service.go new file mode 100644 index 000000000..001459bce --- /dev/null +++ b/updater/internal/install/mocks/service.go @@ -0,0 +1,81 @@ +// Code generated by mockery v2.13.1. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +// Install provides a mock function with given fields: +func (_m *Service) Install() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Start provides a mock function with given fields: +func (_m *Service) Start() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Stop provides a mock function with given fields: +func (_m *Service) Stop() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Uninstall provides a mock function with given fields: +func (_m *Service) Uninstall() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewService interface { + mock.TestingT + Cleanup(func()) +} + +// NewService creates a new instance of Service. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewService(t mockConstructorTestingTNewService) *Service { + mock := &Service{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/updater/internal/install/service.go b/updater/internal/install/service.go new file mode 100644 index 000000000..a9868fbfa --- /dev/null +++ b/updater/internal/install/service.go @@ -0,0 +1,43 @@ +// Copyright observIQ, Inc. +// +// 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 install + +import ( + "bytes" + "os" + "path/filepath" +) + +// Service represents a controllable service +type Service interface { + // Start the service + Start() error + + // Stop the service + Stop() error + + // Installs the service + Install() error + + // Uninstalls the service + Uninstall() error +} + +// replaceInstallDir replaces "[INSTALLDIR]" with the given installDir string. +// This is meant to mimic windows "formatted" string syntax. +func replaceInstallDir(unformattedBytes []byte, installDir string) []byte { + installDirClean := filepath.Clean(installDir) + string(os.PathSeparator) + return bytes.ReplaceAll(unformattedBytes, []byte("[INSTALLDIR]"), []byte(installDirClean)) +} diff --git a/updater/internal/install/service_darwin.go b/updater/internal/install/service_darwin.go new file mode 100644 index 000000000..c8537ebf0 --- /dev/null +++ b/updater/internal/install/service_darwin.go @@ -0,0 +1,113 @@ +// Copyright observIQ, Inc. +// +// 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 darwin + +package install + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const ( + darwinServiceFilePath = "/Library/LaunchDaemons/com.observiq.collector.plist" + darwinInstallDir = "/opt/observiq-otel-collector" +) + +// newService returns an instance of the Service interface for managing the observiq-otel-collector service on the current OS. +func newService(latestPath string) Service { + return &darwinService{ + newServiceFilePath: filepath.Join(latestPath, "install", "com.observiq.collector.plist"), + installedServiceFilePath: darwinServiceFilePath, + installDir: darwinInstallDir, + } +} + +type darwinService struct { + // newServiceFilePath is the file path to the new plist file + newServiceFilePath string + // installedServiceFilePath is the file path to the installed plist file + installedServiceFilePath string + // installDir is the root directory of the main installation + installDir string +} + +// Start the service +func (d darwinService) Start() error { + // Launchctl exits with error code 0 if the file does not exist. + // We want to ensure that we error in this scenario. + if _, err := os.Stat(d.installedServiceFilePath); err != nil { + return fmt.Errorf("failed to stat installed service file: %w", err) + } + + //#nosec G204 -- installedServiceFilePath is not determined by user input + cmd := exec.Command("launchctl", "load", d.installedServiceFilePath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("running launchctl failed: %w", err) + } + return nil +} + +// Stop the service +func (d darwinService) Stop() error { + // Launchctl exits with error code 0 if the file does not exist. + // We want to ensure that we error in this scenario. + if _, err := os.Stat(d.installedServiceFilePath); err != nil { + return fmt.Errorf("failed to stat installed service file: %w", err) + } + + //#nosec G204 -- installedServiceFilePath is not determined by user input + cmd := exec.Command("launchctl", "unload", d.installedServiceFilePath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("running launchctl failed: %w", err) + } + return nil +} + +// Installs the service +func (d darwinService) Install() error { + serviceFileBytes, err := os.ReadFile(d.newServiceFilePath) + if err != nil { + return fmt.Errorf("failed to open input file: %w", err) + } + + expandedServiceFileBytes := replaceInstallDir(serviceFileBytes, d.installDir) + if err := os.WriteFile(d.installedServiceFilePath, expandedServiceFileBytes, 0600); err != nil { + return fmt.Errorf("failed to write service file: %w", err) + } + + return d.Start() +} + +// Uninstalls the service +func (d darwinService) Uninstall() error { + //#nosec G204 -- installedServiceFilePath is not determined by user input + if err := d.Stop(); err != nil { + return err + } + + if err := os.Remove(d.installedServiceFilePath); err != nil { + return fmt.Errorf("failed to remove service file: %w", err) + } + + return nil +} + +// InstallDir returns the filepath to the install directory +func installDir() (string, error) { + return darwinInstallDir, nil +} diff --git a/updater/internal/install/service_darwin_test.go b/updater/internal/install/service_darwin_test.go new file mode 100644 index 000000000..325e41881 --- /dev/null +++ b/updater/internal/install/service_darwin_test.go @@ -0,0 +1,205 @@ +// Copyright observIQ, Inc. +// +// 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 darwin && integration + +package install + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDarwinServiceInstall(t *testing.T) { + t.Run("Test install + uninstall", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "darwin-service.plist") + + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "darwin-service.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Install() + require.NoError(t, err) + require.FileExists(t, installedServicePath) + + // We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + err = d.Uninstall() + require.NoError(t, err) + require.NoFileExists(t, installedServicePath) + + // Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test stop + start", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "darwin-service.plist") + + // TODO: Do this automagically + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "darwin-service.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Install() + require.NoError(t, err) + require.FileExists(t, installedServicePath) + + // We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + err = d.Start() + require.NoError(t, err) + + requireServiceRunning(t) + + err = d.Stop() + require.NoError(t, err) + + requireServiceLoadedStatus(t, false) + + err = d.Uninstall() + require.NoError(t, err) + require.NoFileExists(t, installedServicePath) + + // Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test invalid path for input file", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "darwin-service.plist") + + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "does-not-exist.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Install() + require.ErrorContains(t, err, "failed to open input file") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test invalid path for output file for install", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "directory-does-not-exist", "darwin-service.plist") + + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "darwin-service.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Install() + require.ErrorContains(t, err, "failed to write service file") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Uninstall fails if not installed", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "darwin-service.plist") + + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "darwin-service.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Uninstall() + require.ErrorContains(t, err, "failed to stat installed service file") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Start fails if service not found", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "darwin-service.plist") + + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "darwin-service.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Start() + require.ErrorContains(t, err, "failed to stat installed service file") + }) + + t.Run("Stop fails if service not found", func(t *testing.T) { + installedServicePath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "darwin-service.plist") + + uninstallService(t, installedServicePath) + + d := &darwinService{ + newServiceFilePath: filepath.Join("testdata", "darwin-service.plist"), + installedServiceFilePath: installedServicePath, + } + + err := d.Stop() + require.ErrorContains(t, err, "failed to stat installed service file") + }) +} + +// uninstallService is a helper that uninstalls the service manually for test setup, in case it is somehow leftover. +func uninstallService(t *testing.T, installedPath string) { + t.Helper() + + cmd := exec.Command("launchctl", "unload", installedPath) + // May already be unloaded; We'll ignore the error. + _ = cmd.Run() + + err := os.RemoveAll(installedPath) + require.NoError(t, err) +} + +const exitCodeServiceNotFound = 113 + +func requireServiceLoadedStatus(t *testing.T, loaded bool) { + t.Helper() + + cmd := exec.Command("launchctl", "list", "darwin-service") + err := cmd.Run() + if loaded { + // If the service should be loaded, then we expect a 0 exit code, so no error is given + require.NoError(t, err) + return + } + + eErr, ok := err.(*exec.ExitError) + require.True(t, ok, "launchctl list exited with non-ExitError: %s", eErr) + require.Equal(t, exitCodeServiceNotFound, eErr.ExitCode(), "unexpected exit code when asserting service is unloaded: %d", eErr.ExitCode()) +} + +var descriptionPIDRegex = regexp.MustCompile(`\s*"PID" = \d+;`) + +func requireServiceRunning(t *testing.T) { + t.Helper() + + cmd := exec.Command("launchctl", "list", "darwin-service") + out, err := cmd.Output() + require.NoError(t, err) + matches := descriptionPIDRegex.Match(out) + require.True(t, matches, "Service should be running, but it was not found in launchctl list") +} diff --git a/updater/internal/install/service_linux.go b/updater/internal/install/service_linux.go new file mode 100644 index 000000000..f96d0386f --- /dev/null +++ b/updater/internal/install/service_linux.go @@ -0,0 +1,134 @@ +// Copyright observIQ, Inc. +// +// 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 linux + +package install + +import ( + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" +) + +const linuxServiceName = "observiq-otel-collector" +const linuxServiceFilePath = "/usr/lib/systemd/system/observiq-otel-collector.service" + +// newService returns an instance of the Service interface for managing the observiq-otel-collector service on the current OS. +func newService(latestPath string) Service { + return linuxService{ + newServiceFilePath: filepath.Join(latestPath, "install", "observiq-otel-collector.service"), + serviceName: linuxServiceName, + installedServiceFilePath: linuxServiceFilePath, + } +} + +type linuxService struct { + // newServiceFilePath is the file path to the new unit file + newServiceFilePath string + // serviceName is the name of the service + serviceName string + // installedServiceFilePath is the file path to the installed unit file + installedServiceFilePath string +} + +// Start the service +func (l linuxService) Start() error { + //#nosec G204 -- serviceName is not determined by user input + cmd := exec.Command("systemctl", "start", l.serviceName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("running systemctl failed: %w", err) + } + return nil +} + +// Stop the service +func (l linuxService) Stop() error { + //#nosec G204 -- serviceName is not determined by user input + cmd := exec.Command("systemctl", "stop", l.serviceName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("running systemctl failed: %w", err) + } + return nil +} + +// Installs the service +func (l linuxService) Install() error { + inFile, err := os.Open(l.newServiceFilePath) + if err != nil { + return fmt.Errorf("failed to open input file: %w", err) + } + defer func() { + err := inFile.Close() + if err != nil { + log.Default().Printf("Service Install: Failed to close input file: %s", err) + } + }() + + outFile, err := os.OpenFile(l.installedServiceFilePath, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open output file: %w", err) + } + defer func() { + err := outFile.Close() + if err != nil { + log.Default().Printf("Service Install: Failed to close output file: %s", err) + } + }() + + if _, err := io.Copy(outFile, inFile); err != nil { + return fmt.Errorf("failed to copy service file: %w", err) + } + + cmd := exec.Command("systemctl", "daemon-reload") + if err := cmd.Run(); err != nil { + return fmt.Errorf("reloading systemctl failed: %w", err) + } + + //#nosec G204 -- serviceName is not determined by user input + cmd = exec.Command("systemctl", "enable", l.serviceName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("enabling unit file failed: %w", err) + } + + return nil +} + +// Uninstalls the service +func (l linuxService) Uninstall() error { + //#nosec G204 -- serviceName is not determined by user input + cmd := exec.Command("systemctl", "disable", l.serviceName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to disable unit: %w", err) + } + + if err := os.Remove(l.installedServiceFilePath); err != nil { + return fmt.Errorf("failed to remove service file: %w", err) + } + + cmd = exec.Command("systemctl", "daemon-reload") + if err := cmd.Run(); err != nil { + return fmt.Errorf("reloading systemctl failed: %w", err) + } + + return nil +} + +// installDir returns the filepath to the install directory +func installDir() (string, error) { + return "/opt/observiq-otel-collector", nil +} diff --git a/updater/internal/install/service_linux_test.go b/updater/internal/install/service_linux_test.go new file mode 100644 index 000000000..99ef1f744 --- /dev/null +++ b/updater/internal/install/service_linux_test.go @@ -0,0 +1,214 @@ +// Copyright observIQ, Inc. +// +// 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. + +// an elevated user is needed to run the service tests +//go:build linux && integration + +package install + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// NOTE: These tests must run as root in order to pass +func TestLinuxServiceInstall(t *testing.T) { + t.Run("Test install + uninstall", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "linux-service.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Install() + require.NoError(t, err) + require.FileExists(t, installedServicePath) + + //We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + err = l.Uninstall() + require.NoError(t, err) + require.NoFileExists(t, installedServicePath) + + //Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test stop + start", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "linux-service.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Install() + require.NoError(t, err) + require.FileExists(t, installedServicePath) + + // We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + err = l.Start() + require.NoError(t, err) + + requireServiceRunningStatus(t, true) + + err = l.Stop() + require.NoError(t, err) + + requireServiceRunningStatus(t, false) + + err = l.Uninstall() + require.NoError(t, err) + require.NoFileExists(t, installedServicePath) + + // Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test invalid path for input file", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "does-not-exist.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Install() + require.ErrorContains(t, err, "failed to open input file") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test invalid path for output file for install", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/dir-does-not-exist/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "linux-service.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Install() + require.ErrorContains(t, err, "failed to open output file") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Uninstall fails if not installed", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "linux-service.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Uninstall() + require.ErrorContains(t, err, "failed to disable unit") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Start fails if service not found", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "linux-service.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Start() + require.ErrorContains(t, err, "running systemctl failed") + }) + + t.Run("Stop fails if service not found", func(t *testing.T) { + installedServicePath := "/usr/lib/systemd/system/linux-service.service" + uninstallService(t, installedServicePath, "linux-service") + + l := &linuxService{ + newServiceFilePath: filepath.Join("testdata", "linux-service.service"), + serviceName: "linux-service", + installedServiceFilePath: installedServicePath, + } + + err := l.Stop() + require.ErrorContains(t, err, "running systemctl failed") + }) +} + +// uninstallService is a helper that uninstalls the service manually for test setup, in case it is somehow leftover. +func uninstallService(t *testing.T, installedPath, serviceName string) { + cmd := exec.Command("systemctl", "stop", serviceName) + _ = cmd.Run() + + cmd = exec.Command("systemctl", "disable", serviceName) + _ = cmd.Run() + + err := os.RemoveAll(installedPath) + require.NoError(t, err) + + cmd = exec.Command("systemctl", "daemon-reload") + _ = cmd.Run() +} + +const exitCodeServiceNotFound = 4 +const exitCodeServiceInactive = 3 + +func requireServiceLoadedStatus(t *testing.T, loaded bool) { + t.Helper() + + cmd := exec.Command("systemctl", "status", "linux-service") + err := cmd.Run() + require.Error(t, err, "expected non-zero exit code from 'systemctl status linux-service'") + + eErr, ok := err.(*exec.ExitError) + if loaded { + // If the service should be loaded, then we expect a 0 exit code, so no error is given + require.Equal(t, exitCodeServiceInactive, eErr.ExitCode(), "unexpected exit code when asserting service is unloaded: %d", eErr.ExitCode()) + return + } + + require.True(t, ok, "systemctl status exited with non-ExitError: %s", eErr) + require.Equal(t, exitCodeServiceNotFound, eErr.ExitCode(), "unexpected exit code when asserting service is unloaded: %d", eErr.ExitCode()) +} + +func requireServiceRunningStatus(t *testing.T, running bool) { + cmd := exec.Command("systemctl", "status", "linux-service") + err := cmd.Run() + + if running { + // exit code 0 indicates service is loaded & running + require.NoError(t, err) + return + } + + eErr, ok := err.(*exec.ExitError) + require.True(t, ok, "systemctl status exited with non-ExitError: %s", eErr) + require.Equal(t, exitCodeServiceInactive, eErr.ExitCode(), "unexpected exit code when asserting service is not running: %d", eErr.ExitCode()) +} diff --git a/updater/internal/install/service_test.go b/updater/internal/install/service_test.go new file mode 100644 index 000000000..4cd8ce5a5 --- /dev/null +++ b/updater/internal/install/service_test.go @@ -0,0 +1,54 @@ +// Copyright observIQ, Inc. +// +// 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 install + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReplaceInstallDir(t *testing.T) { + testCases := []struct { + input []byte + installDir string + output []byte + }{ + { + input: []byte("[INSTALLDIR]"), + installDir: "some/install/directory", + output: []byte(filepath.Join("some", "install", "directory") + string(os.PathSeparator)), + }, + { + input: []byte("no install dir"), + installDir: "some/install/directory", + output: []byte("no install dir"), + }, + { + input: []byte("[INSTALLDIR]observiq-otel-collector"), + installDir: "some/install/directory", + output: []byte(filepath.Join("some", "install", "directory", "observiq-otel-collector")), + }, + } + + for _, tc := range testCases { + t.Run(string(tc.input), func(t *testing.T) { + out := replaceInstallDir(tc.input, tc.installDir) + require.Equal(t, tc.output, out) + }) + } +} diff --git a/updater/internal/install/service_windows.go b/updater/internal/install/service_windows.go new file mode 100644 index 000000000..c65a80980 --- /dev/null +++ b/updater/internal/install/service_windows.go @@ -0,0 +1,300 @@ +// Copyright observIQ, Inc. +// +// 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 install + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/kballard/go-shellquote" +) + +const ( + defaultProductName = "observIQ Distro for OpenTelemetry Collector" + defaultServiceName = "observiq-otel-collector" + uninstallServicePollInterval = 50 * time.Millisecond + serviceNotExistErrStr = "The specified service does not exist as an installed service." +) + +// newService returns an instance of the Service interface for managing the observiq-otel-collector service on the current OS. +func newService(latestPath string) Service { + return &windowsService{ + newServiceFilePath: filepath.Join(latestPath, "install", "windows_service.json"), + serviceName: defaultServiceName, + productName: defaultProductName, + } +} + +type windowsService struct { + // newServiceFilePath is the file path to the new unit file + newServiceFilePath string + // serviceName is the name of the service + serviceName string + // productName is the name of the installed product + productName string +} + +// Start the service +func (w windowsService) Start() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(w.serviceName) + if err != nil { + return fmt.Errorf("failed to open service: %w", err) + } + defer s.Close() + + if err := s.Start(); err != nil { + return fmt.Errorf("failed to start service: %w", err) + } + + return nil +} + +// Stop the service +func (w windowsService) Stop() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(w.serviceName) + if err != nil { + return fmt.Errorf("failed to open service: %w", err) + } + defer s.Close() + + if _, err := s.Control(svc.Stop); err != nil { + return fmt.Errorf("failed to start service: %w", err) + } + + return nil +} + +// Installs the service +func (w windowsService) Install() error { + // parse the service definition from disk + wsc, err := readWindowsServiceConfig(w.newServiceFilePath) + if err != nil { + return fmt.Errorf("failed to read service config: %w", err) + } + + // fetch the install directory so that we can determine the binary path that we need to execute + iDir, err := installDirFromRegistry(w.productName) + if err != nil { + return fmt.Errorf("failed to get install dir: %w", err) + } + + // expand the arguments to be properly formatted (expand [INSTALLDIR], clean '"' to be '"') + expandArguments(wsc, w.productName, iDir) + + // Split the arguments; Arguments are "shell-like", in that they may contain spaces, and can be quoted to indicate that. + splitArgs, err := shellquote.Split(wsc.Service.Arguments) + if err != nil { + return fmt.Errorf("failed to parse arguments in service config: %w", err) + } + + // Get the start type + startType, delayed, err := startType(wsc.Service.Start) + if err != nil { + return fmt.Errorf("failed to parse start type in service config: %w", err) + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %w", err) + } + defer m.Disconnect() + + // Create the service using the service manager. + s, err := m.CreateService(w.serviceName, + filepath.Join(iDir, wsc.Path), + mgr.Config{ + Description: wsc.Service.Description, + DisplayName: wsc.Service.DisplayName, + StartType: startType, + DelayedAutoStart: delayed, + }, + splitArgs..., + ) + if err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + defer s.Close() + + return nil +} + +// Uninstalls the service +func (w windowsService) Uninstall() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(w.serviceName) + if err != nil { + return fmt.Errorf("failed to open service: %w", err) + } + + // Note on deleting services in windows: + // Deleting the service is not immediate. If there are open handles to the service (e.g. you have services.msc open) + // then the service deletion will be delayed, perhaps indefinitely. However, we want this logic to be synchronous, so + // we will try to wait for the service to actually be deleted. + if err = s.Delete(); err != nil { + sCloseErr := s.Close() + if sCloseErr != nil { + log.Default().Printf("Failed to close service: %s\n", err) + } + return fmt.Errorf("failed to delete service: %w", err) + } + + if err := s.Close(); err != nil { + return fmt.Errorf("failed to close service: %w", err) + } + + // Wait for the service to actually be deleted: + for { + s, err := m.OpenService(w.serviceName) + if err != nil { + if err.Error() == serviceNotExistErrStr { + // This is expected when the service is uninstalled. + break + } + return fmt.Errorf("got unexpected error when waiting for service deletion: %w", err) + } + + if err := s.Close(); err != nil { + return fmt.Errorf("failed to close service: %w", err) + } + // rest with the handle closed to let the service manager remove the service + time.Sleep(uninstallServicePollInterval) + } + return nil +} + +// windowsServiceConfig defines how the service should be configured, including the entrypoint for the service. +type windowsServiceConfig struct { + // Path is the file that will be executed for the service. It is relative to the install directory. + Path string `json:"path"` + // Configuration for the service (e.g. start type, display name, desc) + Service windowsServiceDefinitionConfig `json:"service"` +} + +// windowsServiceDefinitionConfig defines how the service should be configured. +// Name is a part of the on disk config, but we keep the service name hardcoded; We do not want to use a different service name. +type windowsServiceDefinitionConfig struct { + // Start gives the start type of the service. + // See: https://wixtoolset.org/documentation/manual/v3/xsd/wix/serviceinstall.html + Start string `json:"start"` + // DisplayName is the human-readable name of the service. + DisplayName string `json:"display-name"` + // Description is a human-readable description of the service. + Description string `json:"description"` + // Arguments is a list of space-separated + Arguments string `json:"arguments"` +} + +// readWindowsServiceConfig reads the service config from the file at the given path +func readWindowsServiceConfig(path string) (*windowsServiceConfig, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var wsc windowsServiceConfig + err = json.Unmarshal(b, &wsc) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal json: %w", err) + } + + return &wsc, nil +} + +// expandArguments expands [INSTALLDIR] to the actual install directory and +// expands '"e;' to the literal '"' +func expandArguments(wsc *windowsServiceConfig, productName, installDir string) { + wsc.Service.Arguments = string(replaceInstallDir([]byte(wsc.Service.Arguments), installDir)) + wsc.Service.Arguments = strings.ReplaceAll(wsc.Service.Arguments, """, `"`) +} + +// installDirFromRegistry gets the installation dir of the given product from the Windows Registry +func installDirFromRegistry(productName string) (string, error) { + // this key is created when installing using the MSI installer + keyPath := fmt.Sprintf(`Software\Microsoft\Windows\CurrentVersion\Uninstall\%s`, productName) + key, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.READ) + if err != nil { + return "", fmt.Errorf("failed to open registry key: %w", err) + } + defer func() { + err := key.Close() + if err != nil { + log.Default().Printf("installDirFromRegistry: failed to close registry key") + } + }() + + // This value ("InstallLocation") contains the path to the install folder. + val, _, err := key.GetStringValue("InstallLocation") + if err != nil { + return "", fmt.Errorf("failed to read install dir: %w", err) + } + + return val, nil +} + +// startType converts the start type from the windowsServiceConfig to a start type recognizable by the windows +// service API +func startType(cfgStartType string) (startType uint32, delayed bool, err error) { + switch cfgStartType { + case "auto": + // Automatically starts on system bootup. + startType = mgr.StartAutomatic + case "demand": + // Must be started manually + startType = mgr.StartManual + case "disabled": + // Does not start, must be enabled to run. + startType = mgr.StartDisabled + case "delayed": + // Boots automatically on start, but AFTER bootup has completed. + startType = mgr.StartAutomatic + delayed = true + default: + err = fmt.Errorf("invalid start type in service config: %s", cfgStartType) + } + return +} + +// installDir returns the filepath to the install directory +func installDir() (string, error) { + return installDirFromRegistry(defaultProductName) +} diff --git a/updater/internal/install/service_windows_test.go b/updater/internal/install/service_windows_test.go new file mode 100644 index 000000000..d58c4a1f9 --- /dev/null +++ b/updater/internal/install/service_windows_test.go @@ -0,0 +1,466 @@ +// Copyright observIQ, Inc. +// +// 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. + +// an elevated user is needed to run the service tests +//go:build windows && integration + +package install + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows/registry" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +func TestWindowsServiceInstall(t *testing.T) { + t.Run("Test install + uninstall", func(t *testing.T) { + tempDir := t.TempDir() + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + testServiceProgram := filepath.Join(tempDir, "windows-service.exe") + serviceGoFile, err := filepath.Abs(filepath.Join("testdata", "test-windows-service.go")) + require.NoError(t, err) + + writeServiceFile(t, serviceJSON, filepath.Join("testdata", "windows-service.json"), serviceGoFile) + compileProgram(t, serviceGoFile, testServiceProgram) + + defer uninstallService(t) + createInstallDirRegistryKey(t, testProductName, tempDir) + defer deleteInstallDirRegistryKey(t, testProductName) + + w := &windowsService{ + newServiceFilePath: serviceJSON, + serviceName: "windows-service", + productName: testProductName, + } + + err = w.Install() + require.NoError(t, err) + + //We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + requireServiceConfigMatches(t, + testServiceProgram, + "windows-service", + mgr.StartAutomatic, + "Test Windows Service", + "This is a windows service to test", + true, + []string{ + "--config", + filepath.Join(tempDir, "test.yaml"), + }, + ) + + err = w.Uninstall() + require.NoError(t, err) + + //Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test install + uninstall (space in install folder)", func(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "temp dir with spaces") + require.NoError(t, os.MkdirAll(tempDir, 0777)) + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + testServiceProgram := filepath.Join(tempDir, "windows-service.exe") + serviceGoFile, err := filepath.Abs(filepath.Join("testdata", "test-windows-service.go")) + require.NoError(t, err) + + writeServiceFile(t, serviceJSON, filepath.Join("testdata", "windows-service.json"), serviceGoFile) + compileProgram(t, serviceGoFile, testServiceProgram) + + defer uninstallService(t) + createInstallDirRegistryKey(t, testProductName, tempDir) + defer deleteInstallDirRegistryKey(t, testProductName) + + w := &windowsService{ + newServiceFilePath: serviceJSON, + serviceName: "windows-service", + productName: testProductName, + } + + err = w.Install() + require.NoError(t, err) + + //We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + requireServiceConfigMatches(t, + testServiceProgram, + "windows-service", + mgr.StartAutomatic, + "Test Windows Service", + "This is a windows service to test", + true, + []string{ + "--config", + filepath.Join(tempDir, "test.yaml"), + }, + ) + + err = w.Uninstall() + require.NoError(t, err) + + //Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test stop + start", func(t *testing.T) { + tempDir := t.TempDir() + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + testServiceProgram := filepath.Join(tempDir, "windows-service.exe") + serviceGoFile, err := filepath.Abs(filepath.Join("testdata", "test-windows-service.go")) + require.NoError(t, err) + + writeServiceFile(t, serviceJSON, filepath.Join("testdata", "windows-service.json"), serviceGoFile) + compileProgram(t, serviceGoFile, testServiceProgram) + + defer uninstallService(t) + createInstallDirRegistryKey(t, testProductName, tempDir) + defer deleteInstallDirRegistryKey(t, testProductName) + + w := &windowsService{ + newServiceFilePath: serviceJSON, + serviceName: "windows-service", + productName: testProductName, + } + + err = w.Install() + require.NoError(t, err) + + // We want to check that the service was actually loaded + requireServiceLoadedStatus(t, true) + + err = w.Start() + require.NoError(t, err) + + requireServiceRunningStatus(t, true) + + err = w.Stop() + require.NoError(t, err) + + requireServiceRunningStatus(t, false) + + err = w.Uninstall() + require.NoError(t, err) + + // Make sure the service is no longer listed + requireServiceLoadedStatus(t, false) + }) + + t.Run("Test invalid path for input file", func(t *testing.T) { + tempDir := t.TempDir() + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + testServiceProgram := filepath.Join(tempDir, "windows-service.exe") + serviceGoFile, err := filepath.Abs(filepath.Join("testdata", "test-windows-service.go")) + require.NoError(t, err) + + writeServiceFile(t, serviceJSON, filepath.Join("testdata", "windows-service.json"), serviceGoFile) + compileProgram(t, serviceGoFile, testServiceProgram) + + defer uninstallService(t) + createInstallDirRegistryKey(t, testProductName, tempDir) + defer deleteInstallDirRegistryKey(t, testProductName) + + w := &windowsService{ + newServiceFilePath: filepath.Join(tempDir, "not-a-valid-service.json"), + serviceName: "windows-service", + productName: testProductName, + } + + err = w.Install() + require.ErrorContains(t, err, "The system cannot find the file specified.") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Uninstall fails if not installed", func(t *testing.T) { + tempDir := t.TempDir() + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + + w := &windowsService{ + newServiceFilePath: serviceJSON, + serviceName: "windows-service", + productName: testProductName, + } + + err := w.Uninstall() + require.ErrorContains(t, err, "failed to open service") + requireServiceLoadedStatus(t, false) + }) + + t.Run("Start fails if service not found", func(t *testing.T) { + tempDir := t.TempDir() + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + + w := &windowsService{ + newServiceFilePath: serviceJSON, + serviceName: "windows-service", + productName: testProductName, + } + + err := w.Start() + require.ErrorContains(t, err, "failed to open service") + }) + + t.Run("Stop fails if service not found", func(t *testing.T) { + tempDir := t.TempDir() + testProductName := "Test Product" + + serviceJSON := filepath.Join(tempDir, "windows-service.json") + + w := &windowsService{ + newServiceFilePath: serviceJSON, + serviceName: "windows-service", + productName: testProductName, + } + + err := w.Stop() + require.ErrorContains(t, err, "failed to open service") + }) +} + +func TestStartType(t *testing.T) { + testCases := []struct { + cfgStartType string + startType uint32 + delayed bool + expectedErr string + }{ + { + cfgStartType: "auto", + startType: mgr.StartAutomatic, + delayed: false, + }, + { + cfgStartType: "demand", + startType: mgr.StartManual, + delayed: false, + }, + { + cfgStartType: "disabled", + startType: mgr.StartDisabled, + delayed: false, + }, + { + cfgStartType: "delayed", + startType: mgr.StartAutomatic, + delayed: true, + }, + { + cfgStartType: "not-a-real-start-type", + expectedErr: "invalid start type in service config", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("cfgStartType: %s", tc.cfgStartType), func(t *testing.T) { + st, d, err := startType(tc.cfgStartType) + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + } else { + assert.Equal(t, tc.startType, st) + assert.Equal(t, tc.delayed, d) + } + }) + } +} + +// uninstallService is a helper that uninstalls the service manually for test setup, in case it is somehow leftover. +func uninstallService(t *testing.T) { + m, err := mgr.Connect() + require.NoError(t, err) + defer m.Disconnect() + + s, err := m.OpenService("windows-service") + if err != nil { + // Failed to open the service, we assume it doesn't exist + return + } + defer s.Close() + + status, err := s.Control(svc.Stop) + // If we get an error, the service is likely already in a stopped state. + if err == nil { + for status.State != svc.Stopped { + time.Sleep(100 * time.Millisecond) + status, err = s.Query() + require.NoError(t, err) + } + } + + err = s.Delete() + require.NoError(t, err) +} + +func requireServiceLoadedStatus(t *testing.T, loaded bool) { + t.Helper() + + m, err := mgr.Connect() + require.NoError(t, err, "failed to connect to service manager") + defer m.Disconnect() + + s, err := m.OpenService("windows-service") + if err != nil { + require.False(t, loaded, "Could not connect open service, but service should be loaded") + return + } + defer s.Close() + + require.True(t, loaded, "Connected to open service, but it should not be loaded") + +} + +func requireServiceConfigMatches(t *testing.T, binaryPath, name string, startType uint32, displayName, description string, delayed bool, args []string) { + t.Helper() + + m, err := mgr.Connect() + require.NoError(t, err, "failed to connect to service manager") + defer m.Disconnect() + + s, err := m.OpenService(name) + require.NoError(t, err, "failed to open service") + defer s.Close() + + cfg, err := s.Config() + require.NoError(t, err) + + expectedBinaryPathName := joinArgs(append([]string{binaryPath}, args...)...) + assert.Equal(t, displayName, cfg.DisplayName) + assert.Equal(t, description, cfg.Description) + assert.Equal(t, delayed, cfg.DelayedAutoStart) + assert.Equal(t, startType, cfg.StartType) + assert.Equal(t, expectedBinaryPathName, cfg.BinaryPathName) + // We always install as LocalSystem, which is the "super user" of the system + assert.Equal(t, "LocalSystem", cfg.ServiceStartName) +} + +func requireServiceRunningStatus(t *testing.T, running bool) { + t.Helper() + + m, err := mgr.Connect() + require.NoError(t, err, "failed to connect to service manager") + defer m.Disconnect() + + s, err := m.OpenService("windows-service") + require.NoError(t, err, "Failed to open service") + defer s.Close() + + status, err := s.Query() + require.NoError(t, err, "Failed to query service state") + + if running { + require.Contains(t, []svc.State{svc.StartPending, svc.Running}, status.State) + } else { + require.Contains(t, []svc.State{svc.StopPending, svc.Stopped}, status.State) + } +} + +func writeServiceFile(t *testing.T, outPath, inPath, serviceGoPath string) { + t.Helper() + + b, err := os.ReadFile(inPath) + require.NoError(t, err) + + fileStr := string(b) + fileStr = os.Expand(fileStr, func(s string) string { + switch s { + case "SERVICE_PATH": + return strings.ReplaceAll(serviceGoPath, `\`, `\\`) + } + return "" + }) + + err = os.WriteFile(outPath, []byte(fileStr), 0666) + require.NoError(t, err) +} + +func deleteInstallDirRegistryKey(t *testing.T, productName string) { + t.Helper() + + keyPath := fmt.Sprintf(`Software\Microsoft\Windows\CurrentVersion\Uninstall\%s`, productName) + key, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.WRITE) + if err != nil { + // Key may not exist, assume that's why we couldn't open it + return + } + defer key.Close() + + err = registry.DeleteKey(key, "") + require.NoError(t, err) +} + +func createInstallDirRegistryKey(t *testing.T, productName, installDir string) { + t.Helper() + + installDir, err := filepath.Abs(installDir) + require.NoError(t, err) + installDir += `\` + + keyPath := fmt.Sprintf(`Software\Microsoft\Windows\CurrentVersion\Uninstall\%s`, productName) + key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, keyPath, registry.WRITE) + require.NoError(t, err) + defer key.Close() + + err = key.SetStringValue("InstallLocation", installDir) + require.NoError(t, err) +} + +func compileProgram(t *testing.T, inPath, outPath string) { + t.Helper() + + cmd := exec.Command("go.exe", "build", "-o", outPath, inPath) + err := cmd.Run() + require.NoError(t, err) +} + +func joinArgs(args ...string) string { + sb := strings.Builder{} + for _, arg := range args { + if strings.Contains(arg, " ") { + sb.WriteString(`"`) + sb.WriteString(arg) + sb.WriteString(`"`) + } else { + sb.WriteString(arg) + } + sb.WriteString(" ") + } + + str := sb.String() + return str[:len(str)-1] +} diff --git a/updater/internal/install/testdata/darwin-service.plist b/updater/internal/install/testdata/darwin-service.plist new file mode 100644 index 000000000..f50cabaf7 --- /dev/null +++ b/updater/internal/install/testdata/darwin-service.plist @@ -0,0 +1,15 @@ + + + + + Label + darwin-service + ProgramArguments + + sleep + 1000 + + KeepAlive + + + diff --git a/updater/internal/install/testdata/example-install/config.yaml b/updater/internal/install/testdata/example-install/config.yaml new file mode 100644 index 000000000..ffbf81d31 --- /dev/null +++ b/updater/internal/install/testdata/example-install/config.yaml @@ -0,0 +1 @@ +# This is a placeholder config file diff --git a/updater/internal/install/testdata/example-install/logging.yaml b/updater/internal/install/testdata/example-install/logging.yaml new file mode 100644 index 000000000..cf76a2844 --- /dev/null +++ b/updater/internal/install/testdata/example-install/logging.yaml @@ -0,0 +1 @@ +# This is a placeholder logging.yaml diff --git a/updater/internal/install/testdata/example-install/manager.yaml b/updater/internal/install/testdata/example-install/manager.yaml new file mode 100644 index 000000000..cef5a425c --- /dev/null +++ b/updater/internal/install/testdata/example-install/manager.yaml @@ -0,0 +1 @@ +# manager.yaml should not exist in the archive, but we check for it anyways, just in case. diff --git a/updater/internal/install/testdata/example-install/test-folder/another-test.txt b/updater/internal/install/testdata/example-install/test-folder/another-test.txt new file mode 100644 index 000000000..45b861001 --- /dev/null +++ b/updater/internal/install/testdata/example-install/test-folder/another-test.txt @@ -0,0 +1 @@ +This is a nested text file diff --git a/updater/internal/install/testdata/example-install/test.txt b/updater/internal/install/testdata/example-install/test.txt new file mode 100644 index 000000000..9f4b6d8bf --- /dev/null +++ b/updater/internal/install/testdata/example-install/test.txt @@ -0,0 +1 @@ +This is a test file diff --git a/updater/internal/install/testdata/linux-service.service b/updater/internal/install/testdata/linux-service.service new file mode 100644 index 000000000..5d72584a2 --- /dev/null +++ b/updater/internal/install/testdata/linux-service.service @@ -0,0 +1,10 @@ +[Unit] +Description=Test service +After=network.target +[Service] +Type=simple +User=root +ExecStart=sleep 1000 +SuccessExitStatus=0 +[Install] +WantedBy=multi-user.target diff --git a/updater/internal/install/testdata/test-windows-service.go b/updater/internal/install/testdata/test-windows-service.go new file mode 100644 index 000000000..c7acecef3 --- /dev/null +++ b/updater/internal/install/testdata/test-windows-service.go @@ -0,0 +1,55 @@ +// Copyright observIQ, Inc. +// +// 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 ( + "log" + + "golang.org/x/sys/windows/svc" +) + +func main() { + winSvc, err := svc.IsWindowsService() + if err != nil { + log.Fatalf("Failed to determine if we were a windows service") + } + + if !winSvc { + log.Fatalf("This program must be run as a windows service") + } + + err = svc.Run("", &windowsService{}) + if err != nil { + log.Fatalf("Failed to run service: %s", err) + } + +} + +type windowsService struct{} + +func (sh *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) { + s <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + for { + req := <-r + switch req.Cmd { + case svc.Interrogate: + s <- req.CurrentStatus + case svc.Stop, svc.Shutdown: + return false, 0 + default: + return false, 1052 + } + } +} diff --git a/updater/internal/install/testdata/windows-service.json b/updater/internal/install/testdata/windows-service.json new file mode 100644 index 000000000..2805e768f --- /dev/null +++ b/updater/internal/install/testdata/windows-service.json @@ -0,0 +1,10 @@ +{ + "path": "windows-service.exe", + "service": { + "name": "windows-service", + "start": "delayed", + "display-name": "Test Windows Service", + "description": "This is a windows service to test", + "arguments": "--config "[INSTALLDIR]test.yaml"" + } +}