diff -Nru wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/main.go wsl-pro-service-0.1.4/cmd/wsl-pro-service/main.go --- wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/main.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/cmd/wsl-pro-service/main.go 2024-03-15 11:59:36.000000000 +0000 @@ -11,6 +11,7 @@ "github.com/canonical/ubuntu-pro-for-wsl/common" "github.com/canonical/ubuntu-pro-for-wsl/common/i18n" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/cmd/wsl-pro-service/service" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/consts" log "github.com/sirupsen/logrus" ) @@ -40,7 +41,7 @@ ForceColors: true, }) - log.Infof("Starting WSL Pro Service version %s", common.Version) + log.Infof("Starting WSL Pro Service version %s", consts.Version) if err := a.Run(); err != nil { log.Error(context.Background(), err) diff -Nru wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/main_test.go wsl-pro-service-0.1.4/cmd/wsl-pro-service/main_test.go --- wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/main_test.go 2024-02-06 14:05:35.000000000 +0000 +++ wsl-pro-service-0.1.4/cmd/wsl-pro-service/main_test.go 2024-03-07 16:11:12.000000000 +0000 @@ -20,7 +20,6 @@ "Send SIGTERM exits": {sendSig: syscall.SIGTERM}, } for name, tc := range tests { - tc := tc t.Run(name, func(t *testing.T) { // Signal handlers tests: can’t be parallel @@ -78,7 +77,6 @@ "Run and usage error only does not fail": {usageErrorReturn: true, runError: false, wantReturnCode: 0}, } for name, tc := range tests { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() diff -Nru wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/export_test.go wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/export_test.go --- wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/export_test.go 2024-01-31 09:50:53.000000000 +0000 +++ wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/export_test.go 2024-04-03 11:34:21.000000000 +0000 @@ -2,7 +2,7 @@ import "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" -func WithSystem(s system.System) func(*options) { +func WithSystem(s *system.System) func(*options) { return func(o *options) { o.system = s } diff -Nru wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/service.go wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/service.go --- wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/service.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/service.go 2024-04-03 11:34:21.000000000 +0000 @@ -7,14 +7,13 @@ log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" "github.com/canonical/ubuntu-pro-for-wsl/common/i18n" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/commandservice" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/consts" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/daemon" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/wslinstanceservice" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubuntu/decorate" ) // cmdName is the binary name for the service. @@ -36,7 +35,7 @@ } type options struct { - system system.System + system *system.System } type option func(*options) @@ -95,24 +94,25 @@ f(&opt) } - srv := wslinstanceservice.New(opt.system) - // Connect with the agent. - a.daemon, err = daemon.New(ctx, srv.RegisterGRPCService, opt.system) + a.daemon, err = daemon.New(ctx, opt.system) if err != nil { close(a.ready) return fmt.Errorf("could not create daemon: %v", err) } + service := commandservice.New(opt.system) close(a.ready) - return a.daemon.Serve() + return a.daemon.Serve(service) } // installVerbosityFlag adds the -v and -vv options and returns the reference to it. func installVerbosityFlag(cmd *cobra.Command, viper *viper.Viper) *int { r := cmd.PersistentFlags().CountP("verbosity", "v", i18n.G("issue INFO (-v), DEBUG (-vv) or DEBUG with caller (-vvv) output")) - decorate.LogOnError(viper.BindPFlag("verbosity", cmd.PersistentFlags().Lookup("verbosity"))) + if err := viper.BindPFlag("verbosity", cmd.PersistentFlags().Lookup("verbosity")); err != nil { + log.Warning(context.Background(), err) + } return r } @@ -146,9 +146,6 @@ // Quit gracefully shutdown the service. func (a *App) Quit() { a.WaitReady() - if a.daemon == nil { - return - } a.daemon.Quit(context.Background(), false) } diff -Nru wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/service_test.go wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/service_test.go --- wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/service_test.go 2024-02-14 13:33:28.000000000 +0000 +++ wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/service_test.go 2024-04-03 11:34:21.000000000 +0000 @@ -11,6 +11,7 @@ "time" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/cmd/wsl-pro-service/service" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/consts" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/testutils" log "github.com/sirupsen/logrus" @@ -66,7 +67,7 @@ } require.Equal(t, want, fields[0], "Wrong executable name") - require.Equal(t, "Dev", fields[1], "Wrong version") + require.Equal(t, consts.Version, fields[1], "Wrong version") } func TestNoUsageError(t *testing.T) { @@ -103,13 +104,14 @@ defer cancel() system, mock := testutils.MockSystem(t) - srv, _ := testutils.MockWindowsAgent(t, ctx, mock.DefaultAddrFile()) + agent := testutils.NewMockWindowsAgent(t, ctx, mock.DefaultPublicDir()) + defer agent.Stop() a, wait := startDaemon(t, system) defer wait() time.Sleep(time.Second) - srv.Stop() + agent.Stop() a.Quit() } @@ -121,7 +123,8 @@ defer cancel() system, mock := testutils.MockSystem(t) - testutils.MockWindowsAgent(t, ctx, mock.DefaultAddrFile()) + agent := testutils.NewMockWindowsAgent(t, ctx, mock.DefaultPublicDir()) + defer agent.Stop() a, wait := startDaemon(t, system) @@ -159,6 +162,9 @@ a := service.New(service.WithSystem(sys)) + agent := testutils.NewMockWindowsAgent(t, context.Background(), mock.DefaultPublicDir()) + defer agent.Stop() + a.SetArgs() defer a.Quit() @@ -190,7 +196,7 @@ // startDaemon prepares and starts the daemon in the background. The done function should be called // to wait for the daemon to stop. -func startDaemon(t *testing.T, s system.System) (app *service.App, done func()) { +func startDaemon(t *testing.T, s *system.System) (app *service.App, done func()) { t.Helper() a := service.New(service.WithSystem(s)) diff -Nru wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/version.go wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/version.go --- wsl-pro-service-0.1.2build1/cmd/wsl-pro-service/service/version.go 2024-01-31 09:50:53.000000000 +0000 +++ wsl-pro-service-0.1.4/cmd/wsl-pro-service/service/version.go 2024-03-15 11:59:36.000000000 +0000 @@ -3,8 +3,8 @@ import ( "fmt" - "github.com/canonical/ubuntu-pro-for-wsl/common" "github.com/canonical/ubuntu-pro-for-wsl/common/i18n" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/consts" "github.com/spf13/cobra" ) @@ -20,6 +20,6 @@ // getVersion returns the current service version. func getVersion() (err error) { - fmt.Printf(i18n.G("%s\t%s")+"\n", cmdName, common.Version) + fmt.Printf(i18n.G("%s\t%s")+"\n", cmdName, consts.Version) return nil } diff -Nru wsl-pro-service-0.1.2build1/debian/changelog wsl-pro-service-0.1.4/debian/changelog --- wsl-pro-service-0.1.2build1/debian/changelog 2024-04-01 08:33:04.000000000 +0000 +++ wsl-pro-service-0.1.4/debian/changelog 2024-04-19 05:56:41.000000000 +0000 @@ -1,8 +1,26 @@ -wsl-pro-service (0.1.2build1) noble; urgency=medium +wsl-pro-service (0.1.4) noble; urgency=medium - * No-change rebuild for CVE-2024-3094 + * Vendor manually on the host as the go mod vendoring when using + dpkg-buildpackage works in a different environment. - -- William Grant Mon, 01 Apr 2024 19:33:04 +1100 + -- Didier Roche-Tolomelli Fri, 19 Apr 2024 07:56:41 +0200 + +wsl-pro-service (0.1.3) noble; urgency=medium + + * Pin Go toolchain to 1.22.2 to fix the following security vulnerabilities: + - GO-2024-2687 + * Use self-signed certificate chain to communicate with the Windows Agent over mTLS. (LP: #2060548) + * Simplify the communication with the agent removing the double server. + * Send the READY signal to systemd earlier. + * Renamed ubuntu-advantage-tools to ubuntu-pro-client (LP: #2057651) + * Restrict landscape.conf file permissions. (thanks iosifache) + * Adds a default 'wsl' tag to Landscape configs. + * Removed and obfuscated log messages that could leak sensitive information. + * More robust handling output of cmd.exe commands. + * More careful validation of the .address file contents. + * Updated dependencies. + + -- Carlos Nihelton Thu, 18 Apr 2024 12:43:23 -0300 wsl-pro-service (0.1.2) noble; urgency=medium diff -Nru wsl-pro-service-0.1.2build1/debian/control wsl-pro-service-0.1.4/debian/control --- wsl-pro-service-0.1.2build1/debian/control 2024-02-29 14:21:20.000000000 +0000 +++ wsl-pro-service-0.1.4/debian/control 2024-04-03 11:34:21.000000000 +0000 @@ -6,7 +6,7 @@ Build-Depends: debhelper-compat (= 13), dh-apport, dh-golang, - golang-go (>= 2:1.21~), + golang-go (>= 2:1.22~), Standards-Version: 4.6.2 XS-Go-Import-Path: github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service Homepage: https://github.com/canonical/ubuntu-pro-for-wsl @@ -25,7 +25,7 @@ Built-Using: ${misc:Built-Using}, Depends: ${shlibs:Depends}, ${misc:Depends}, - ubuntu-advantage-tools, + ubuntu-pro-client, Recommends: landscape-client, Description: ${source:Synopsis} - WSL service ${source:Extended-Description} diff -Nru wsl-pro-service-0.1.2build1/debian/prepare-source.sh wsl-pro-service-0.1.4/debian/prepare-source.sh --- wsl-pro-service-0.1.2build1/debian/prepare-source.sh 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/debian/prepare-source.sh 2024-04-19 05:56:41.000000000 +0000 @@ -0,0 +1,22 @@ +#!/bin/bash +set -eu + +# Path to the versioning file. +# We must compute the versiong during the source package build because we still have access to git. +# We can't do this during the binary package, so we cannot use the usual -ldflags. +# Instead, we write down the version to a file during source build, and read the file during binary build. +VERSION_FILE="./version" + +export GOWORK=off + +#is_source_build=$(git status > /dev/null 2>&1 && echo "1" || true) + +# Handle vendoring and version detection +#if [ -n "${is_source_build}" ]; then +# go run ../tools/build/compute_version.go > ${VERSION_FILE} +# rm -r vendor &> /dev/null || true +# go mod vendor +#fi + +version=$(cat ${VERSION_FILE}) +echo -ldflags=-X=github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/consts.Version=${version} diff -Nru wsl-pro-service-0.1.2build1/debian/rules wsl-pro-service-0.1.4/debian/rules --- wsl-pro-service-0.1.2build1/debian/rules 2024-02-29 14:21:20.000000000 +0000 +++ wsl-pro-service-0.1.4/debian/rules 2024-04-19 05:53:29.000000000 +0000 @@ -10,12 +10,13 @@ # as long as it matches the go.mod go stenza which is the language requirement. export GOTOOLCHAIN := local +# Computes the version ID and updates vendoring +export GOFLAGS := $(shell ./debian/prepare-source.sh) + %: + @echo "Building with flags $(GOFLAGS)" dh $@ --builddirectory=_build --with=apport -execute_after_dh_auto_clean: - [ -d vendor/ ] || ( ./debian/update-internal-dependencies && GOWORK=off go mod vendor ) - override_dh_auto_install: dh_auto_install -- --no-source diff -Nru wsl-pro-service-0.1.2build1/go.mod wsl-pro-service-0.1.4/go.mod --- wsl-pro-service-0.1.2build1/go.mod 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/go.mod 2024-04-19 05:32:36.000000000 +0000 @@ -1,28 +1,27 @@ module github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service -go 1.21.0 +go 1.22.0 -toolchain go1.21.5 +toolchain go1.22.2 require ( - github.com/canonical/ubuntu-pro-for-wsl/agentapi v0.0.0-20240226091256-9b51218be4d4 - github.com/canonical/ubuntu-pro-for-wsl/common v0.0.0-20240226180423-1b7275d345b1 - github.com/canonical/ubuntu-pro-for-wsl/wslserviceapi v0.0.0-20240226091256-9b51218be4d4 + github.com/canonical/ubuntu-pro-for-wsl/agentapi v0.0.0-20240412151705-5e7385e21896 + github.com/canonical/ubuntu-pro-for-wsl/common v0.0.0-20240418154853-80f760b5094c github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 - github.com/stretchr/testify v1.8.4 - github.com/ubuntu/decorate v0.0.0-20230905131025-e968fa48a85c - golang.org/x/exp v0.0.0-20231006140011-7918f672742d - google.golang.org/grpc v1.62.0 + github.com/stretchr/testify v1.9.0 + github.com/ubuntu/decorate v0.0.0-20240301153420-5015d6dbc8e5 + golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc + google.golang.org/grpc v1.63.2 gopkg.in/ini.v1 v1.67.0 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -39,11 +38,11 @@ github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/net v0.20.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0 // indirect - google.golang.org/protobuf v1.32.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff -Nru wsl-pro-service-0.1.2build1/go.sum wsl-pro-service-0.1.4/go.sum --- wsl-pro-service-0.1.2build1/go.sum 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/go.sum 2024-04-19 05:32:36.000000000 +0000 @@ -1,9 +1,7 @@ -github.com/canonical/ubuntu-pro-for-wsl/agentapi v0.0.0-20240226091256-9b51218be4d4 h1:dtuEydh6hINxREJuRV0xrZUsYB00tX1U6/zGTc5RsOU= -github.com/canonical/ubuntu-pro-for-wsl/agentapi v0.0.0-20240226091256-9b51218be4d4/go.mod h1:XGv0USTAhbDJ8Jsf7yOSQ/xthh9gc/IgiOp+IVDyF2s= -github.com/canonical/ubuntu-pro-for-wsl/common v0.0.0-20240226180423-1b7275d345b1 h1:DTKEM13spFq9DWDAd0sri8omi8H57idVZXQUvGLOsOA= -github.com/canonical/ubuntu-pro-for-wsl/common v0.0.0-20240226180423-1b7275d345b1/go.mod h1:y4P4oOdZ3Mx5KDnYwa90DRw0IBknYE/+Lq3GY24vAxw= -github.com/canonical/ubuntu-pro-for-wsl/wslserviceapi v0.0.0-20240226091256-9b51218be4d4 h1:27S4h1GmIDS2JphBCnuX0v4hQQslLXgSrK5FxGo//eo= -github.com/canonical/ubuntu-pro-for-wsl/wslserviceapi v0.0.0-20240226091256-9b51218be4d4/go.mod h1:TiNdnL3LcO335OZGxrQE7c7ArOtq80EBW4/s020HoKs= +github.com/canonical/ubuntu-pro-for-wsl/agentapi v0.0.0-20240412151705-5e7385e21896 h1:HcDXZSv7jO0M6ZBeNnAOjq6Bm9ieaKcm8zj8z8681BU= +github.com/canonical/ubuntu-pro-for-wsl/agentapi v0.0.0-20240412151705-5e7385e21896/go.mod h1:8cMWt+GvjqWKV/HQLEkCM5bnBWMiXnfKn0UVjgNoW6U= +github.com/canonical/ubuntu-pro-for-wsl/common v0.0.0-20240418154853-80f760b5094c h1:g/zMaNebTSJATOmNsOtc+Sxmr+hXyk0HywVzc+HyJd4= +github.com/canonical/ubuntu-pro-for-wsl/common v0.0.0-20240418154853-80f760b5094c/go.mod h1:STWxCjA2cGy3wWzSdBawiWi//J38nTOwf6eVqN9DlyM= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -15,10 +13,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -38,8 +34,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -68,36 +64,34 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/ubuntu/decorate v0.0.0-20230905131025-e968fa48a85c h1:jO41xNLddTDkrfz4w4RCMWCmX8Y+ZHz5jSbJWNLDvqU= -github.com/ubuntu/decorate v0.0.0-20230905131025-e968fa48a85c/go.mod h1:edGgz97NOqS2oqzbKrZqO9YU9neosRrkEZbVJVQynAA= +github.com/ubuntu/decorate v0.0.0-20240301153420-5015d6dbc8e5 h1:qO8m+4mLbo1HRpD5lfhEfr7R1PuqZvbAmjaRzYEy+tM= +github.com/ubuntu/decorate v0.0.0-20240301153420-5015d6dbc8e5/go.mod h1:PUpwIgUuCQyuCz/gwiq6WYbo7IvtXXd8JqL01ez+jZE= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= +golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= -google.golang.org/grpc v1.62.0 h1:HQKZ/fa1bXkX1oFOvSjmZEUL8wLSaZTjCcLAlmZRtdk= -google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0 h1:rNBFJjBCOgVr9pWD7rs/knKL4FRTKgpZmsRfV214zcA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff -Nru wsl-pro-service-0.1.2build1/internal/commandservice/commandservice.go wsl-pro-service-0.1.4/internal/commandservice/commandservice.go --- wsl-pro-service-0.1.2build1/internal/commandservice/commandservice.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/commandservice/commandservice.go 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,67 @@ +// Package commandservice is the implementation of the wsl instance API. +package commandservice + +import ( + "context" + + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + "github.com/canonical/ubuntu-pro-for-wsl/common" + log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" +) + +// Service is the object in charge of communicating to the Windows agent. +type Service struct { + system *system.System +} + +// New creates a new Wsl instance Service with the provided system. +func New(s *system.System) Service { + return Service{ + system: s, + } +} + +// ApplyProToken serves ApplyProToken messages sent by the agent. +func (s Service) ApplyProToken(ctx context.Context, info *agentapi.ProAttachCmd) (err error) { + if info.GetToken() == "" { + log.Info(ctx, "ApplyProToken: Received empty token: detaching") + } else { + log.Infof(ctx, "ApplyProToken: Received token %q: attaching", common.Obfuscate(info.GetToken())) + } + + if err := s.system.ProDetach(ctx); err != nil { + return err + } + + if info.GetToken() == "" { + return nil + } + + if err := s.system.ProAttach(ctx, info.GetToken()); err != nil { + return err + } + + return nil +} + +// ApplyLandscapeConfig serves LandscapeConfig messages sent by the agent. +func (s Service) ApplyLandscapeConfig(ctx context.Context, msg *agentapi.LandscapeConfigCmd) (err error) { + conf := msg.GetConfig() + if conf == "" { + log.Info(ctx, "ApplyLandscapeConfig: received empty config: disabling") + if err := s.system.LandscapeDisable(ctx); err != nil { + return err + } + return nil + } + + uid := msg.GetHostagentUid() + + log.Infof(ctx, "ApplyLandscapeConfig: received config: registering") + if err := s.system.LandscapeEnable(ctx, conf, uid); err != nil { + return err + } + + return nil +} diff -Nru wsl-pro-service-0.1.2build1/internal/commandservice/commandservice_test.go wsl-pro-service-0.1.4/internal/commandservice/commandservice_test.go --- wsl-pro-service-0.1.2build1/internal/commandservice/commandservice_test.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/commandservice/commandservice_test.go 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,155 @@ +package commandservice_test + +import ( + "context" + "testing" + + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/commandservice" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyProToken(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + emptyToken bool + + breakProAttach bool + breakProDetach bool + + wantDetach bool + wantAttach bool + wantErr bool + }{ + "Success attaching": {wantDetach: true, wantAttach: true}, + "Success detaching": {emptyToken: true, wantDetach: true}, + + // Attach/detach errors + "Error calling pro detach": {breakProDetach: true, wantErr: true}, + "Error calling pro attach": {breakProAttach: true, wantErr: true}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + token := "123abc" + if tc.emptyToken { + token = "" + } + + system, mock := testutils.MockSystem(t) + + if tc.breakProAttach { + mock.SetControlArg(testutils.ProAttachErr) + } + + if tc.breakProDetach { + mock.SetControlArg(testutils.ProDetachErrGeneric) + } + + svc := commandservice.New(system) + + err := svc.ApplyProToken(context.Background(), &agentapi.ProAttachCmd{Token: token}) + if tc.wantErr { + require.Error(t, err, "ApplyProToken call should return an error") + return + } + require.NoError(t, err, "ApplyProToken should return no error") + + p := mock.Path("/.pro-detached") + if tc.wantDetach { + assert.FileExists(t, p, "Pro executable should have been called to pro-detach") + } else { + assert.NoFileExists(t, p, "Pro executable should not have been called to pro-detach") + } + + p = mock.Path("/.pro-attached") + if tc.wantAttach { + assert.FileExists(t, p, "Pro executable should have been called to pro-attach") + } else { + assert.NoFileExists(t, p, "Pro executable should not have been called to pro-attach") + } + }) + } +} + +func TestApplyLandscapeConfig(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + emptyConfig bool + + breakLandscapeEnable bool + breakLandscapeDisable bool + + wantErr bool + + wantDisableCalled bool + wantEnableCalled bool + }{ + "Success enabling Landscape": {wantEnableCalled: true}, + "Success disabling Landscape": {emptyConfig: true, wantDisableCalled: true}, + + // Attach/detach errors + "Error calling landscape disable": {emptyConfig: true, breakLandscapeDisable: true, wantErr: true}, + "Error calling landscape enable": {breakLandscapeEnable: true, wantErr: true}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + uid := "this-is-a-uid" + config := "[client]\nhello=world" + if tc.emptyConfig { + config = "" + } + + sys, mock := testutils.MockSystem(t) + + if tc.breakLandscapeEnable { + mock.SetControlArg(testutils.LandscapeEnableErr) + } + + if tc.breakLandscapeDisable { + mock.SetControlArg(testutils.LandscapeDisableErr) + } + + svc := commandservice.New(sys) + + err := svc.ApplyLandscapeConfig(context.Background(), &agentapi.LandscapeConfigCmd{ + Config: config, + HostagentUid: uid, + }) + if tc.wantErr { + require.Error(t, err, "ApplyLandscapeConfig call should return an error") + return + } + require.NoError(t, err, "ApplyLandscapeConfig call should return no error") + + p := mock.Path("/.landscape-disabled") + if tc.wantDisableCalled { + assert.FileExists(t, p, "Landscape executable should have been called to disable") + } else { + assert.NoFileExists(t, p, "Landscape executable should not have been called to disable") + } + + p = mock.Path("/.landscape-enabled") + if tc.wantEnableCalled { + assert.FileExists(t, p, "Landscape executable should have been called to enable it") + } else { + assert.NoFileExists(t, p, "Landscape executable should not have been called to enable it") + } + }) + } +} + +func TestWithProMock(t *testing.T) { testutils.ProMock(t) } +func TestWithLandscapeConfigMock(t *testing.T) { testutils.LandscapeConfigMock(t) } +func TestWithWslPathMock(t *testing.T) { testutils.WslPathMock(t) } +func TestWithWslInfoMock(t *testing.T) { testutils.WslInfoMock(t) } +func TestWithCmdExeMock(t *testing.T) { testutils.CmdExeMock(t) } diff -Nru wsl-pro-service-0.1.2build1/internal/commandservice/testdata/TestApplyLandscapeConfig/golden/success_enabling wsl-pro-service-0.1.4/internal/commandservice/testdata/TestApplyLandscapeConfig/golden/success_enabling --- wsl-pro-service-0.1.2build1/internal/commandservice/testdata/TestApplyLandscapeConfig/golden/success_enabling 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/commandservice/testdata/TestApplyLandscapeConfig/golden/success_enabling 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,7 @@ +[hello] +world = true + +[client] +computer_title = TEST_DISTRO +hostagent_uid = landscapeHostagent1234 +tags = wsl diff -Nru wsl-pro-service-0.1.2build1/internal/consts/consts.go wsl-pro-service-0.1.4/internal/consts/consts.go --- wsl-pro-service-0.1.2build1/internal/consts/consts.go 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/consts/consts.go 2024-03-15 11:59:36.000000000 +0000 @@ -1,7 +1,9 @@ // Package consts defines the constants used by the project package consts -import log "github.com/sirupsen/logrus" +import ( + log "github.com/sirupsen/logrus" +) const ( // DefaultLogLevel is the default logging level selected without any option. diff -Nru wsl-pro-service-0.1.2build1/internal/consts/version.go wsl-pro-service-0.1.4/internal/consts/version.go --- wsl-pro-service-0.1.2build1/internal/consts/version.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/consts/version.go 2024-03-15 11:59:36.000000000 +0000 @@ -0,0 +1,4 @@ +package consts + +// Version is the version name for Ubuntu Pro for WSL. +var Version = "Dev" diff -Nru wsl-pro-service-0.1.2build1/internal/controlstream/controlstream.go wsl-pro-service-0.1.4/internal/controlstream/controlstream.go --- wsl-pro-service-0.1.2build1/internal/controlstream/controlstream.go 2024-02-29 14:21:20.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/controlstream/controlstream.go 1970-01-01 00:00:00.000000000 +0000 @@ -1,170 +0,0 @@ -// Package controlstream encapsulates details of the connection to the control stream served by the Windows Agent. -package controlstream - -import ( - "context" - "errors" - "fmt" - "net" - "os" - "path/filepath" - - agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" - "github.com/canonical/ubuntu-pro-for-wsl/common" - log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" - "github.com/ubuntu/decorate" - "google.golang.org/grpc/connectivity" -) - -// ControlStream manages the connection to the control stream served by the Windows Agent. -type ControlStream struct { - system system.System - addrPath string - session session - port int -} - -// SystemError is an error caused by a misconfiguration of the system, rather than -// originated from Ubuntu Pro for WSL. -type SystemError struct { - error -} - -func systemErrorf(msg string, args ...any) SystemError { - return SystemError{fmt.Errorf(msg, args...)} -} - -func (err SystemError) Error() string { - return err.error.Error() -} - -// New creates an idle control stream object. -func New(ctx context.Context, s system.System) (ControlStream, error) { - home, err := s.UserProfileDir(ctx) - if err != nil { - return ControlStream{}, fmt.Errorf("could not find address file: could not find $env:UserProfile: %v", err) - } - - return ControlStream{ - addrPath: filepath.Join(home, common.UserProfileDir, common.ListeningPortFileName), - system: s, - }, nil -} - -// Connect connects to the control stream. Call Disconnect to release resources. -func (cs *ControlStream) Connect(ctx context.Context) (err error) { - defer decorate.OnError(&err, "could not connect to Windows Agent via the control stream") - - ctrlAddr, err := cs.address(ctx) - if err != nil { - return fmt.Errorf("could not get address: %w", err) - } - - distroName, err := cs.system.WslDistroName(ctx) - if err != nil { - log.Warningf(ctx, "Controlstream: assigning arbitrary connection ID because of error: %v", err) - distroName = "" - } - - session, err := newSession(ctx, ctrlAddr, distroName) - if err != nil { - return err - } - - log.Debug(ctx, "Control stream: starting handshake") - - port, err := cs.handshake(ctx, session) - if err != nil { - return err - } - - log.Debug(ctx, "Control stream: completed handshake") - - cs.session = session - cs.port = port - - return nil -} - -func (cs *ControlStream) handshake(ctx context.Context, session session) (port int, err error) { - defer decorate.OnError(&err, "could not complete handshake") - - sysinfo, err := cs.system.Info(ctx) - if err != nil { - return 0, systemErrorf("could not obtain system info: %v", err) - } - - if err := session.send(sysinfo); err != nil { - return 0, err - } - - message, err := session.recv() - if err != nil { - return 0, err - } - - p := message.GetPort() - if p == 0 { - return 0, errors.New("received invalid message: port cannot be zero") - } - - return net.LookupPort("tcp4", fmt.Sprint(p)) -} - -// Disconnect dumps the existing connection (if any). The connection can be re-established by calling Connect. -func (cs *ControlStream) Disconnect() { - cs.session.close() - cs.port = 0 -} - -// address fetches the address of the control stream from the Windows filesystem. -func (cs ControlStream) address(ctx context.Context) (string, error) { - windowsLocalhost, err := cs.system.WindowsHostAddress(ctx) - if err != nil { - return "", SystemError{err} - } - - /* - We parse the port from the file written by the windows agent. - */ - addr, err := os.ReadFile(cs.addrPath) - if err != nil { - return "", fmt.Errorf("could not read agent port file %q: %v", cs.addrPath, err) - } - - _, port, err := net.SplitHostPort(string(addr)) - if err != nil { - return "", fmt.Errorf("could not parse port from %q: %v", addr, err) - } - - return net.JoinHostPort(windowsLocalhost.String(), port), nil -} - -// ReservedPort returns the port assigned to this distro. -func (cs ControlStream) ReservedPort() int { - return cs.port -} - -// Send sends info about the system to the Windows Agent. -func (cs ControlStream) Send(info *agentapi.DistroInfo) error { - return cs.session.send(info) -} - -// Done returns a channel that blocks for as long as the connection to the stream lasts. -// Cancel the context to release resources. -func (cs ControlStream) Done(ctx context.Context) <-chan struct{} { - ch := make(chan struct{}) - - conn := cs.session.conn - if conn == nil { - close(ch) - return ch - } - - go func() { - defer close(ch) - conn.WaitForStateChange(ctx, connectivity.Ready) - }() - return ch -} diff -Nru wsl-pro-service-0.1.2build1/internal/controlstream/controlstream_test.go wsl-pro-service-0.1.4/internal/controlstream/controlstream_test.go --- wsl-pro-service-0.1.2build1/internal/controlstream/controlstream_test.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/controlstream/controlstream_test.go 1970-01-01 00:00:00.000000000 +0000 @@ -1,312 +0,0 @@ -package controlstream_test - -import ( - "context" - "errors" - "fmt" - "io/fs" - "net" - "os" - "testing" - "time" - - agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/controlstream" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/testutils" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" -) - -func TestMain(m *testing.M) { - log.SetLevel(log.DebugLevel) - - m.Run() -} - -func TestNew(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - breakWslPath bool - precancelContext bool - - wantErr bool - }{ - "Success": {}, - - "Error when the context is cancelled": {precancelContext: true, wantErr: true}, - "Error when WslPath returns error": {breakWslPath: true, wantErr: true}, - } - - for name, tc := range testCases { - tc := tc - t.Run(name, func(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sys, mock := testutils.MockSystem(t) - - if tc.breakWslPath { - mock.SetControlArg(testutils.WslpathErr) - } - if tc.precancelContext { - cancel() - } - - _, err := controlstream.New(ctx, sys) - if tc.wantErr { - require.Error(t, err, "New should return an error") - return - } - - require.NoError(t, err, "New should return no error") - }) - } -} - -func TestConnect(t *testing.T) { - t.Parallel() - - type dataFileState int - - const ( - dataFileGood dataFileState = iota - dataFileUnreadable - dataFileNotExist - dataFileEmpty - dataFileBadSyntax - dataFileBadData - ) - - testCases := map[string]struct { - portFile dataFileState - breakWindowsLocalhost bool - breakWSlDistroName bool - - agentDoesntRecv bool - agentSendsNoPort bool - agentSendsBadPort bool - - wantErr bool - }{ - "Success": {}, - - // Port file errors - "No connection because port file does not exist": {portFile: dataFileNotExist, wantErr: true}, - "No connection because of unreadable port file": {portFile: dataFileUnreadable, wantErr: true}, - "No connection because of empty port file": {portFile: dataFileEmpty, wantErr: true}, - "No connection because of port file with invalid contents": {portFile: dataFileBadSyntax, wantErr: true}, - "No connection because of port file contains the wrong port": {portFile: dataFileBadData, wantErr: true}, - - // Network errors - "Error because WindowsForwardedLocalhost returns error": {breakWindowsLocalhost: true, wantErr: true}, - - // Agent errors - "Incomplete handshake because Agent never receives": {agentDoesntRecv: true, wantErr: true}, - "Incomplete handshake because Agent never sends a port": {agentSendsNoPort: true, wantErr: true}, - "Incomplete handshake because Agent sends port :0": {agentSendsBadPort: true, wantErr: true}, - - // Other errors - "Error when system cannot retrieve the WSL distro name": {breakWSlDistroName: true, wantErr: true}, - } - - for name, tc := range testCases { - tc := tc - t.Run(name, func(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - system, mock := testutils.MockSystem(t) - - if tc.breakWindowsLocalhost { - mock.SetControlArg(testutils.WslInfoErr) - } - - var agentArgs []testutils.AgentOption - if tc.agentDoesntRecv { - agentArgs = append(agentArgs, testutils.WithDropStreamBeforeReceivingInfo()) - } else if tc.agentSendsNoPort { - agentArgs = append(agentArgs, testutils.WithDropStreamBeforeSendingPort()) - } else if tc.agentSendsBadPort { - agentArgs = append(agentArgs, testutils.WithSendBadPort()) - } - - portFile := mock.DefaultAddrFile() - _, agentMetaData := testutils.MockWindowsAgent(t, ctx, portFile, agentArgs...) - - switch tc.portFile { - case dataFileGood: - case dataFileNotExist: - err := os.Remove(portFile) - require.NoError(t, err, "Setup: could not remove port file") - case dataFileUnreadable: - err := os.Remove(portFile) - require.NoError(t, err, "Setup: could not remove port file") - err = os.Mkdir(portFile, 0600) - require.NoError(t, err, "Setup: could not create directory where port file should be") - case dataFileEmpty: - f, err := os.Create(portFile) - require.NoError(t, err, "Setup: failed to create empty port file") - f.Close() - case dataFileBadSyntax: - err := os.WriteFile(portFile, []byte("This text is not a valid IP address"), 0600) - require.NoError(t, err, "Setup: failed to create port file with invalid contents") - case dataFileBadData: - lis, err := net.Listen("tcp4", "localhost:") - require.NoError(t, err, "Setup: could not reserve an IP address to mess with port file") - wrongAddr := lis.Addr().String() - - err = os.WriteFile(portFile, []byte(wrongAddr), 0600) - require.NoError(t, err, "Setup: failed to create port file with misleading contents") - - err = lis.Close() - require.NoError(t, err, "Setup: failed to close port file used to select wrong port") - default: - require.Fail(t, "Test setup error", "Unexpected enum value %d for portFile state", tc.portFile) - } - - cs, err := controlstream.New(ctx, system) - require.NoError(t, err, "New should return no error") - - if tc.breakWSlDistroName { - // Must be set after New to avoid breaking system.UserProfileDir - mock.SetControlArg(testutils.WslpathErr) - mock.WslDistroName = "" - } - - select { - case <-cs.Done(ctx): - case <-time.After(time.Second): - require.Fail(t, "Done should not block before the control stream is connected") - } - - err = cs.Connect(ctx) - if tc.wantErr { - require.Error(t, err, "Connect should have returned an error") - return - } - require.NoError(t, err, "Connect should have returned no error") - defer cs.Disconnect() - - require.Equal(t, int32(1), agentMetaData.ConnectionCount.Load(), "The agent should have received one connection") - require.Equal(t, agentMetaData.ReservedPort.Load(), uint32(cs.ReservedPort()), "The Windows agent and the Daemon should agree on the reserved port") - - select { - case <-cs.Done(ctx): - require.Fail(t, "Done should not return while the control stream is connected") - case <-time.After(time.Second): - } - - cs.Disconnect() - - select { - case <-cs.Done(ctx): - case <-time.After(time.Second): - require.Fail(t, "Done should not block after the control stream is disconnected") - } - - // Ensure no panics - cs.Disconnect() - }) - } -} - -func TestSend(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - system, mock := testutils.MockSystem(t) - - portFile := mock.DefaultAddrFile() - _, agentMetaData := testutils.MockWindowsAgent(t, ctx, portFile) - - cs, err := controlstream.New(ctx, system) - require.NoError(t, err, "New should return no error") - - err = cs.Connect(ctx) - require.NoError(t, err, "Connect should have returned no error") - defer cs.Disconnect() - - require.Equal(t, int32(1), agentMetaData.ConnectionCount.Load(), "The agent should have received one connection via the control stream") - require.Equal(t, int32(1), agentMetaData.RecvCount.Load(), "The agent should have received one message via the control stream") - - var c net.ListenConfig - l, err := c.Listen(ctx, "tcp4", fmt.Sprintf("localhost:%d", cs.ReservedPort())) - require.NoError(t, err, "could not serve assigned port") - defer l.Close() - - err = cs.Send(&agentapi.DistroInfo{WslName: "HELLO"}) - require.NoError(t, err, "Send should return no error") - - require.Eventually(t, func() bool { - return agentMetaData.RecvCount.Load() > 1 - }, 20*time.Second, time.Second, "The agent should have received another message via the control stream") - - require.Equal(t, int32(2), agentMetaData.RecvCount.Load(), "The agent should have received exactly two messages via the control stream") -} - -func TestReconnection(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - firstConnectionSuccesful bool - }{ - "Success connecting after failing to connect": {}, - "Success connecting after previous connection dropped": {firstConnectionSuccesful: true}, - } - - for name, tc := range testCases { - tc := tc - t.Run(name, func(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - system, mock := testutils.MockSystem(t) - portFile := mock.DefaultAddrFile() - - cs, err := controlstream.New(ctx, system) - require.NoError(t, err, "New should return no error") - defer cs.Disconnect() - - var server *grpc.Server - if tc.firstConnectionSuccesful { - server, _ = testutils.MockWindowsAgent(t, ctx, portFile) - } - - err = cs.Connect(ctx) - if tc.firstConnectionSuccesful { - require.NoError(t, err, "First connection should return no error") - server.Stop() - - // Avoid a race where the portfile is not removed until after the next server starts - require.Eventually(t, func() bool { - _, err := os.Stat(portFile) - return errors.Is(err, fs.ErrNotExist) - }, 5*time.Second, 100*time.Millisecond, "Stopping the server should remove the port file") - } else { - require.Error(t, err, "First connection should return an error") - } - - cs.Disconnect() - server, _ = testutils.MockWindowsAgent(t, ctx, portFile) - defer server.Stop() - - err = cs.Connect(ctx) - require.NoError(t, err, "Second connection should return no error") - }) - } -} - -func TestWithProMock(t *testing.T) { testutils.ProMock(t) } -func TestWithWslPathMock(t *testing.T) { testutils.WslPathMock(t) } -func TestWithWslInfoMock(t *testing.T) { testutils.WslInfoMock(t) } -func TestWithCmdExeMock(t *testing.T) { testutils.CmdExeMock(t) } diff -Nru wsl-pro-service-0.1.2build1/internal/controlstream/session.go wsl-pro-service-0.1.4/internal/controlstream/session.go --- wsl-pro-service-0.1.2build1/internal/controlstream/session.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/controlstream/session.go 1970-01-01 00:00:00.000000000 +0000 @@ -1,69 +0,0 @@ -package controlstream - -import ( - "context" - "errors" - "fmt" - - agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" - "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/interceptorschain" - log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" - "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - -// session represents a connection to the control stream. Every time the connection drops, -// the session object is rendered unusable and a new session must be created. -type session struct { - stream agentapi.WSLInstance_ConnectedClient - conn *grpc.ClientConn -} - -// newSession starts a connection to the control stream. Call close to release resources. -func newSession(ctx context.Context, address, clientID string) (s session, err error) { - log.Infof(ctx, "Connecting to control stream at %q", address) - - s.conn, err = grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithStreamInterceptor(interceptorschain.StreamClient( - log.StreamClientInterceptor(logrus.StandardLogger(), log.WithClientID(clientID)), - ))) - - if err != nil { - return session{}, fmt.Errorf("could not dial: %v", err) - } - - client := agentapi.NewWSLInstanceClient(s.conn) - s.stream, err = client.Connected(ctx) - if err != nil { - return session{}, fmt.Errorf("could not connect to GRPC service: %v", err) - } - - return s, nil -} - -// close stops the connection (if there is one) and releases resources. -func (s *session) close() { - if s.conn != nil { - _ = s.conn.Close() - } -} - -// send sends a DistroInfo message. -func (s session) send(sysinfo *agentapi.DistroInfo) error { - if s.stream == nil { - return errors.New("could not send system info: disconnected") - } - if err := s.stream.Send(sysinfo); err != nil { - return fmt.Errorf("could not send system info: %v", err) - } - return nil -} - -// recv blocks until a message from the agent is received. -func (s session) recv() (*agentapi.Port, error) { - if s.stream == nil { - return nil, errors.New("could not receive a port: disconnected") - } - return s.stream.Recv() -} diff -Nru wsl-pro-service-0.1.2build1/internal/daemon/daemon.go wsl-pro-service-0.1.4/internal/daemon/daemon.go --- wsl-pro-service-0.1.2build1/internal/daemon/daemon.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/daemon/daemon.go 2024-04-19 05:32:36.000000000 +0000 @@ -3,46 +3,61 @@ import ( "context" + "crypto/tls" + "crypto/x509" "errors" "fmt" "net" + "os" + "path/filepath" + "strconv" "sync/atomic" "time" + "github.com/canonical/ubuntu-pro-for-wsl/common" + "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/interceptorschain" log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" "github.com/canonical/ubuntu-pro-for-wsl/common/i18n" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/controlstream" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/streams" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/wslinstanceservice" "github.com/coreos/go-systemd/daemon" + "github.com/sirupsen/logrus" + "github.com/ubuntu/decorate" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" ) // Daemon is a grpc daemon with systemd support. type Daemon struct { - ctrlStream *controlstream.ControlStream - registerService GRPCServiceRegisterer + addressPath, certsPath string - // ctx and cancel used to stop the currently active service. - ctx context.Context - cancel func() - - // Channels for internal messaging. - started atomic.Bool - running chan struct{} - gracefulStop func() - forceStop func() + // Interface to the WSL distro + system *system.System // Systemd status management. systemdSdNotifier systemdSdNotifier + + // Channels for internal messaging. + started atomic.Bool + running chan struct{} + + // This context is used to interrupt any action. + // It must be the parent of gracefulCtx. + ctx context.Context + cancel context.CancelFunc + + // This context waits until the next blocking Recv to interrupt. + gracefulCtx context.Context + gracefulCancel context.CancelFunc } // Status sent to systemd. const ( - serviceStatusWaiting = "Not serving: waiting to retry" - serviceStatusRetrying = "Not serving: retrying" - serviceStatusServing = "Serving" - serviceStatusStopped = "Stopped" + serviceStatusWaiting = "Not connected: waiting to retry" + serviceStatusConnecting = "Connecting" + serviceStatusConnected = "Connected" + serviceStatusStopped = "Stopped" ) type options struct { @@ -54,12 +69,9 @@ // Option is the function signature used to tweak the daemon creation. type Option func(*options) -// GRPCServiceRegisterer is a function that the daemon will call everytime we want to build a new GRPC object. -type GRPCServiceRegisterer func(context.Context, wslinstanceservice.ControlStreamClient) *grpc.Server - // New returns an new, initialized daemon server, which handles systemd activation. // If systemd activation is used, it will override any socket passed here. -func New(ctx context.Context, registerGRPCService GRPCServiceRegisterer, s system.System, args ...Option) (*Daemon, error) { +func New(ctx context.Context, s *system.System, args ...Option) (*Daemon, error) { log.Debug(ctx, "Building new daemon") // Set default options. @@ -72,175 +84,131 @@ f(&opts) } - ctrlStream, err := controlstream.New(ctx, s) + home, err := s.UserProfileDir(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("could not find address file: could not find $env:UserProfile: %v", err) } ctx, cancel := context.WithCancel(ctx) + gCtx, gCancel := context.WithCancel(ctx) return &Daemon{ - registerService: registerGRPCService, systemdSdNotifier: opts.systemdSdNotifier, - ctrlStream: &ctrlStream, - ctx: ctx, - cancel: cancel, - }, nil -} + system: s, + addressPath: filepath.Join(home, common.UserProfileDir, common.ListeningPortFileName), + certsPath: filepath.Join(home, common.UserProfileDir, common.CertificatesDir), -// Serve sets up the GRPC server to listen to the address reserved by the -// control stream. If either the server or the connection to the stream -// fail, both server and stream are restarted. -func (d *Daemon) Serve() (err error) { - defer func() { - if err := d.systemdNotifyStatus(d.ctx, serviceStatusStopped); err != nil { - log.Warningf(d.ctx, "Could not change systemd status: %v", err) - } - }() + ctx: ctx, + cancel: cancel, - select { - case <-d.ctx.Done(): - return d.ctx.Err() - default: - } + gracefulCtx: gCtx, + gracefulCancel: gCancel, + }, nil +} - var gracefulStopCtx context.Context - gracefulStopCtx, d.gracefulStop = context.WithCancel(d.ctx) - defer d.gracefulStop() - - var forceStopCtx context.Context - forceStopCtx, d.forceStop = context.WithCancel(d.ctx) - defer d.forceStop() +// Serve serves on the streams, automatically reconnecting when the connection drops. +// Call Quit to deallocate the resources used in Serve. +func (d *Daemon) Serve(service streams.CommandService) error { + defer d.cancel() + defer d.systemdNotifyStatus(d.ctx, serviceStatusStopped) d.running = make(chan struct{}) defer close(d.running) d.started.Store(true) + select { + case <-d.gracefulCtx.Done(): + return errors.New("already quit") + default: + } + + // Exponential back-off const ( - minDelay = 1 * time.Second - maxDelay = 5 * time.Minute - growthRate = 2 + minWait = time.Second + maxWait = time.Minute + growthFactor = 2 ) + wait := 0 * time.Second - delay := minDelay - - if err := d.systemdNotifyReady(d.ctx); err != nil { - return err + // Signal systemd before dialing for the first time + // We don't want to delay startup due to a timeout + err := d.systemdNotifyReady(d.ctx) + if err != nil { + return fmt.Errorf("could not notify systemd: %v", err) } for { - err := d.serveOnce(gracefulStopCtx, forceStopCtx) - if err == nil { - return nil - } - var target controlstream.SystemError - if errors.As(err, &target) { - // Irrecoverable errors: broken /etc/resolv.conf, broken pro status, etc - return err - } - log.Errorf(d.ctx, "Serve error: %v", err) - - delay = min(delay*growthRate, maxDelay) - - if err := d.systemdNotifyStatus(d.ctx, serviceStatusWaiting); err != nil { - return err - } - select { - case <-d.ctx.Done(): - return d.ctx.Err() - case <-time.After(delay): - case <-forceStopCtx.Done(): - return nil - case <-gracefulStopCtx.Done(): + case <-d.gracefulCtx.Done(): return nil + case <-time.After(wait): } - log.Infof(d.ctx, "Retrying connection to control stream") - if err := d.systemdNotifyStatus(d.ctx, serviceStatusRetrying); err != nil { - return err - } - } -} + success, err := func() (success bool, err error) { + // ctx handles force-quit + ctx, cancel := context.WithCancel(d.ctx) + defer cancel() + + log.Info(ctx, "Daemon: connecting to Windows Agent") + d.systemdNotifyStatus(ctx, serviceStatusConnecting) + + server, err := d.connect(ctx) + if errors.Is(err, streams.SystemError{}) { + return false, err + } else if err != nil { + log.Warningf(ctx, "Daemon: %v", err) + return false, nil + } + + go func() { + // Handle graceful quit. + select { + case <-d.gracefulCtx.Done(): + case <-ctx.Done(): + } + server.GracefulStop() + }() + + log.Info(ctx, "Daemon: completed connection to Windows Agent") + d.systemdNotifyStatus(ctx, serviceStatusConnected) + + t := time.NewTimer(time.Minute) + defer t.Stop() + + err = server.Serve(service) + + if errors.Is(err, streams.SystemError{}) { + return false, err + } else if err != nil { + log.Warningf(ctx, "Daemon: disconnected from Windows host: %v", err) + } else { + log.Warning(ctx, "Daemon: disconnected from Windows host") + } + + select { + case <-t.C: + // Long-lived connection is not a failure + return true, nil + default: + // Connection was short-lived: consider it a failure + return false, nil + } + }() -func (d *Daemon) serveOnce(gracefulStopCtx, forceStopCtx context.Context) error { - ctx, cancel := context.WithCancel(d.ctx) - defer cancel() - - // Initial setup - if err := d.ctrlStream.Connect(ctx); err != nil { - return err - } - defer d.ctrlStream.Disconnect() - log.Infof(ctx, "Connected to control stream") - - server := d.registerService(ctx, d.ctrlStream) - go handleServerStop(ctx, gracefulStopCtx, forceStopCtx, server) - - // Start serving - serveDone := make(chan error) - go func() { - defer close(serveDone) - serveDone <- d.serve(ctx, server) - }() - - // Block until either the service or the control stream stops - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-serveDone: if err != nil { - return fmt.Errorf("WSL Pro Service stopped serving: %v", err) + return err } - return nil - case <-d.ctrlStream.Done(ctx): - return errors.New("lost connection to Windows Agent") - } -} - -func handleServerStop(ctx, gracefulStopCtx, forceStopCtx context.Context, server *grpc.Server) { - defer server.Stop() - - select { - case <-ctx.Done(): - return - case <-forceStopCtx.Done(): - return - case <-gracefulStopCtx.Done(): - server.GracefulStop() - } - // Graceful stop can be overridden by a later forced Stop - select { - case <-ctx.Done(): - case <-forceStopCtx.Done(): - } -} - -// serve listens on a tcp socket and starts serving GRPC requests on it. -func (d *Daemon) serve(ctx context.Context, server *grpc.Server) error { - log.Debug(ctx, "Starting to serve gRPC requests") - - address := fmt.Sprintf("localhost:%d", d.ctrlStream.ReservedPort()) - - var cfg net.ListenConfig - lis, err := cfg.Listen(ctx, "tcp4", address) - if err != nil { - return fmt.Errorf("could not listen: %v", err) - } - - log.Infof(ctx, "Serving gRPC requests on %v", address) - - if err := d.systemdNotifyStatus(d.ctx, serviceStatusServing); err != nil { - return err - } + if success { + wait *= 0 + continue + } - if err := server.Serve(lis); err != nil { - return fmt.Errorf("grpc error: %v", err) + wait = clamp(minWait, wait*growthFactor, maxWait) + log.Infof(d.ctx, "Reconnecting to Windows host in %d seconds", int(wait/time.Second)) + d.systemdNotifyStatus(d.ctx, serviceStatusWaiting) } - - return nil } // Quit gracefully quits listening loop and stops the grpc server. @@ -248,21 +216,20 @@ func (d *Daemon) Quit(ctx context.Context, force bool) { defer d.cancel() - if !d.started.Load() { - return - } - // Signal log.Info(ctx, "Stopping daemon requested.") if force { - d.forceStop() - <-d.running - return + d.cancel() + log.Info(ctx, i18n.G("Stopping active requests.")) + } else { + d.gracefulCancel() + log.Info(ctx, i18n.G("Waiting for active requests to close.")) } - d.gracefulStop() + if !d.started.Load() { + return + } - log.Info(ctx, i18n.G("Waiting for active requests to close.")) <-d.running log.Debug(ctx, i18n.G("All connections have now ended.")) } @@ -278,7 +245,7 @@ return nil } -func (d *Daemon) systemdNotifyStatus(ctx context.Context, status string) error { +func (d *Daemon) systemdNotifyStatus(ctx context.Context, status string) { message := fmt.Sprintf("STATUS=%s", status) // ^^ // You may think that this should be %q, but you'd be wrong! @@ -289,10 +256,131 @@ sent, err := d.systemdSdNotifier(false, message) if err != nil { - return fmt.Errorf("couldn't update status to systemd: %v", err) + log.Warningf(ctx, "Daemon: couldn't update systemd status to %q: %v", status, err) + return } + if sent { log.Debugf(ctx, "Updated systemd status to %q", status) } - return nil +} + +func clamp(minimum, value, maximum time.Duration) time.Duration { + return max(minimum, min(value, maximum)) +} + +// connect connects to the Windows Agent and returns a reverse server. +// Cancel the context to quit gracefully, or Stop the server to abort. +func (d *Daemon) connect(ctx context.Context) (server *streams.Server, err error) { + defer decorate.OnError(&err, "could not connect to Windows Agent") + + addr, err := d.address(ctx, d.system) + if err != nil { + return nil, fmt.Errorf("could not get address: %w", err) + } + + distroName, err := d.system.WslDistroName(ctx) + if err != nil { + log.Warningf(ctx, "Windows host connection: assigning arbitrary connection ID because of error: %v", err) + distroName = "" + } + + log.Infof(ctx, "Daemon: starting connection to Windows Agent via %s", addr) + + tlsConfig, err := newTLSConfigFromDir(d.certsPath) + if err != nil { + return nil, err + } + conn, err := grpc.DialContext(ctx, addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithStreamInterceptor(interceptorschain.StreamClient( + log.StreamClientInterceptor(logrus.StandardLogger(), log.WithClientID(distroName)), + )), grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + if err != nil { + return nil, fmt.Errorf("could not dial: %v", err) + } + + defer func(err *error) { + if *err != nil { + conn.Close() + } + }(&err) + + return streams.NewServer(ctx, d.system, conn), nil +} + +// newTLSConfigFromDir loads certificates from the provided certs path and returns a matching tls.Config. +func newTLSConfigFromDir(certsPath string) (conf *tls.Config, err error) { + decorate.OnError(&err, "could not load TLS config") + + cert, err := tls.LoadX509KeyPair(filepath.Join(certsPath, common.ClientsCertFilePrefix+common.CertificateSuffix), filepath.Join(certsPath, common.ClientsCertFilePrefix+common.KeySuffix)) + if err != nil { + return nil, err + } + + ca := x509.NewCertPool() + caFilePath := filepath.Join(certsPath, common.RootCACertFileName) + caBytes, err := os.ReadFile(caFilePath) + if err != nil { + return nil, err + } + if ok := ca.AppendCertsFromPEM(caBytes); !ok { + return nil, fmt.Errorf("failed to parse %q", caFilePath) + } + + return &tls.Config{ + ServerName: common.GRPCServerNameOverride, + Certificates: []tls.Certificate{cert}, + RootCAs: ca, + MinVersion: tls.VersionTLS13, + }, nil +} + +// address fetches the address of the control stream from the Windows filesystem. +func (d *Daemon) address(ctx context.Context, system *system.System) (string, error) { + // Parse the port from the file written by the windows agent. + addr, err := os.ReadFile(d.addressPath) + if err != nil { + return "", fmt.Errorf("could not read agent port file %q: %v", d.addressPath, err) + } + + port, err := splitPort(string(addr)) + if err != nil { + return "", err + } + + windowsLocalhost, err := system.WindowsHostAddress(ctx) + if err != nil { + return "", streams.NewSystemError("%w", err) + } + + // Join the address and port, and validate it. + address := net.JoinHostPort(windowsLocalhost.String(), fmt.Sprint(port)) + + return address, nil +} + +// splitPort splits the port from the address, and validates that the port is a strictly positive integer. +func splitPort(addr string) (p int, err error) { + defer decorate.OnError(&err, "could not parse port from %q", addr) + + _, port, err := net.SplitHostPort(addr) + if err != nil { + return 0, fmt.Errorf("could not split address: %v", err) + } + + p, err = strconv.Atoi(port) + if err != nil { + return 0, fmt.Errorf("could not parse port as an integer: %v", err) + } + + if p == 0 { + return 0, errors.New("port cannot be zero") + } + + if p < 0 { + return 0, errors.New("port cannot be negative") + } + + return p, nil } diff -Nru wsl-pro-service-0.1.2build1/internal/daemon/daemon_test.go wsl-pro-service-0.1.4/internal/daemon/daemon_test.go --- wsl-pro-service-0.1.2build1/internal/daemon/daemon_test.go 2024-02-14 13:33:28.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/daemon/daemon_test.go 2024-04-19 05:32:36.000000000 +0000 @@ -3,19 +3,19 @@ import ( "context" "errors" - "io/fs" "os" + "path/filepath" "strings" "sync/atomic" "testing" "time" + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + "github.com/canonical/ubuntu-pro-for-wsl/common" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/daemon" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/testutils" - "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/wslinstanceservice" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" - "google.golang.org/grpc" ) func TestMain(m *testing.M) { @@ -37,7 +37,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -50,7 +49,7 @@ mock.SetControlArg(testutils.WslpathErr) } - _, err := daemon.New(ctx, nil, sys) + _, err := daemon.New(ctx, sys) if tc.wantErr { require.Error(t, err, "New should return an error") return @@ -66,41 +65,50 @@ testCases := map[string]struct { precancelContext bool - breakPortFile bool breakWindowsHostAddress bool - - // Breaking the agent - agentDoesntRecv bool - agentSendsNoPort bool - agentSendsBadPort bool + dontServe bool + missingCertsDir bool + missingCaCert bool + + // Break the port file in various ways + breakPortFile bool + portFileEmpty bool + portFilePortNotNumber bool + portFileZeroPort bool + portFileNegativePort bool // Return values for the mock SystemdSdNotifier notifierReturn bool notifierErr bool - wantSystemdNotReady bool - wantConnectControlStream bool - wantErr bool + wantSystemdNotReady bool + wantConnected bool + wantErr bool }{ - "Success": {wantConnectControlStream: true}, - "Success with systemd notifier returning true": {notifierReturn: true, wantConnectControlStream: true}, + "Success": {wantConnected: true}, + "Success with systemd notifier returning true": {notifierReturn: true, wantConnected: true}, // No connection: // These problems do not cause the agent to return error because it // keeps retrying the connection // - // We instead chech that a connection was/wasn't made with the agent, and that systemd was notified - "No connection because port file does not exist": {breakPortFile: true}, - "No connection because of faulty agent": {agentDoesntRecv: true, wantConnectControlStream: true}, + // We instead check that a connection was/wasn't made with the agent, and that systemd was notified + "No connection because the port file does not exist": {breakPortFile: true, wantConnected: false}, + "No connection because the port file is empty": {portFileEmpty: true, wantConnected: false}, + "No connection because the port file has a bad port": {portFilePortNotNumber: true, wantConnected: false}, + "No connection because the port file has port 0": {portFileZeroPort: true, wantConnected: false}, + "No connection because the port file has a negative port": {portFileNegativePort: true, wantConnected: false}, + "No connection because there is no server": {dontServe: true}, + "No connection because there are no certificates": {missingCertsDir: true, wantConnected: false}, + "No connection because cannot read root CA certificate file": {missingCaCert: true, wantConnected: false}, // Errors - "Error because of notifier returning error": {notifierErr: true, wantErr: true}, - "Error because WindowsHostAddress returns error": {breakWindowsHostAddress: true, wantErr: true}, - "Error because of context cancelled": {precancelContext: true, wantSystemdNotReady: true, wantErr: true}, + "Error because the context is pre-cancelled": {precancelContext: true, wantSystemdNotReady: true, wantErr: true}, + "Error because the notifier returns an error": {notifierErr: true, wantErr: true}, + "Error because WindowsHostAddress returns an error": {breakWindowsHostAddress: true, wantErr: true}, } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -109,68 +117,117 @@ system, mock := testutils.MockSystem(t) - var agentArgs []testutils.AgentOption - if tc.agentDoesntRecv { - agentArgs = append(agentArgs, testutils.WithDropStreamBeforeReceivingInfo()) - } else if tc.agentSendsNoPort { - agentArgs = append(agentArgs, testutils.WithDropStreamBeforeSendingPort()) - } else if tc.agentSendsBadPort { - agentArgs = append(agentArgs, testutils.WithSendBadPort()) + publicDir := mock.DefaultPublicDir() + agent := testutils.NewMockWindowsAgent(t, ctx, publicDir) + defer agent.Stop() + + if tc.missingCertsDir { + require.NoError(t, os.RemoveAll(filepath.Join(publicDir, common.CertificatesDir)), "Setup: could not remove certificates") } - portFile := mock.DefaultAddrFile() - _, agentMetaData := testutils.MockWindowsAgent(t, ctx, portFile, agentArgs...) + if tc.missingCaCert { + require.NoError(t, os.RemoveAll(filepath.Join(publicDir, common.CertificatesDir, common.RootCACertFileName)), "Setup: could not remove the root CA certificate file") + } if tc.breakPortFile { - err := os.Remove(portFile) - require.NoError(t, err, "Setup: could not remove port file") + require.NoError(t, os.RemoveAll(publicDir), "Setup: could not remove port file") } if tc.breakWindowsHostAddress { mock.SetControlArg(testutils.WslInfoErr) } - registerService := func(context.Context, wslinstanceservice.ControlStreamClient) *grpc.Server { - // No need for an actual service - return grpc.NewServer() + portFile := filepath.Join(publicDir, common.ListeningPortFileName) + if tc.portFileEmpty { + require.NoError(t, os.WriteFile(portFile, []byte{}, 0600), "Setup: could not overwrite port file") + } + if tc.portFilePortNotNumber { + require.NoError(t, os.WriteFile(portFile, []byte("127.0.0.1:portyMcPortface"), 0600), "Setup: could not overwrite port file") + } + if tc.portFileZeroPort { + require.NoError(t, os.WriteFile(portFile, []byte("127.0.0.1:0"), 0600), "Setup: could not overwrite port file") + } + if tc.portFileNegativePort { + require.NoError(t, os.WriteFile(portFile, []byte("127.0.0.1:-5"), 0600), "Setup: could not overwrite port file") + } + if tc.dontServe { + addr := agent.Listener.Addr().String() + agent.Stop() + require.NoError(t, os.WriteFile(portFile, []byte(addr), 0600), "Setup: could not overwrite port file") } - systemd := SystemdSdNotifierMock{ + systemd := &SystemdSdNotifierMock{ returns: tc.notifierReturn, returnErr: tc.notifierErr, } - d, err := daemon.New( - ctx, - registerService, - system, - daemon.WithSystemdNotifier(systemd.notify), - ) + d, err := daemon.New(ctx, system, daemon.WithSystemdNotifier(systemd.notify)) require.NoError(t, err, "New should return no error") if tc.precancelContext { cancel() } - time.AfterFunc(10*time.Second, func() { d.Quit(ctx, true) }) + serveExit := make(chan error) + go func() { + serveExit <- d.Serve(&mockService{}) + close(serveExit) + }() - err = d.Serve() - if tc.wantErr { - require.Error(t, err, "Serve() should have returned an error") + if tc.wantConnected { + require.Eventually(t, func() bool { + return systemd.gotState.Load() == "STATUS=Connected" + }, 30*time.Second, time.Second, "Systemd never switched states to 'Connected'") + + require.Eventually(t, agent.Service.AllConnected, + 30*time.Second, time.Second, "The daemon should have connected to the Windows Agent") + + require.Eventually(t, func() bool { + conOk := len(agent.Service.Connect.History()) > 0 + proOk := len(agent.Service.ProAttachment.History()) > 0 + lpeOk := len(agent.Service.LandscapeConfig.History()) > 0 + return conOk && proOk && lpeOk + }, 30*time.Second, time.Second, "The server should have been sent the Hello message on every stream") + } else if tc.wantErr { + select { + case err := <-serveExit: + require.Error(t, err, "Serve should have returned an error") + case <-time.After(30 * time.Second): + require.Fail(t, "Serve should have returned an error, but is still serving") + } } else { - require.NoError(t, err, "Serve() should have returned no error") + // Not connected, but no return either: silent error and retrial + require.Eventually(t, func() bool { + return strings.HasPrefix(systemd.gotState.Load(), "STATUS=Not connected") + }, 30*time.Second, time.Second, "Systemd never switched states to 'Not connected'") + } + + d.Quit(ctx, false) + + if !tc.wantErr { + select { + case err := <-serveExit: + require.NoError(t, err, "Serve() should have returned no error") + case <-time.After(30 * time.Second): + require.Fail(t, "Serve should have exited after calling Quit") + } } if tc.wantSystemdNotReady { require.Zero(t, systemd.readyNotifications.Load(), "daemon should not have notified systemd") } else { - require.Equal(t, int32(1), systemd.readyNotifications.Load(), "daemon should have notified systemd once") + require.EqualValues(t, 1, systemd.readyNotifications.Load(), "daemon should have notified systemd once") } - if tc.wantConnectControlStream { - require.NotZero(t, agentMetaData.ConnectionCount.Load(), "daemon should have succefully connected to the agent") - } else { - require.Zero(t, agentMetaData.ConnectionCount.Load(), "daemon should not have connected to the agent") + if tc.dontServe { + return // Nothing to assert server-side + } + + if !tc.wantConnected { + require.Zero(t, agent.Service.Connect.NConnections(), "daemon should not have connected to the agent (connected stream)") + require.Zero(t, agent.Service.ProAttachment.NConnections(), "daemon should not have connected to the agent (pro attach stream)") + require.Zero(t, agent.Service.LandscapeConfig.NConnections(), "daemon should not have connected to the agent (landscape config stream)") + return } }) } @@ -195,7 +252,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -204,32 +260,23 @@ system, mock := testutils.MockSystem(t) - portFile := mock.DefaultAddrFile() - testutils.MockWindowsAgent(t, ctx, portFile) - - registerer := func(ctx context.Context, ctrl wslinstanceservice.ControlStreamClient) *grpc.Server { - // No need for a real GRPC service - return grpc.NewServer() - } + publicDir := mock.DefaultPublicDir() + agent := testutils.NewMockWindowsAgent(t, ctx, publicDir) - systemd := SystemdSdNotifierMock{ + systemd := &SystemdSdNotifierMock{ returns: true, } - d, err := daemon.New(ctx, - registerer, - system, - daemon.WithSystemdNotifier(systemd.notify), - ) + d, err := daemon.New(ctx, system, daemon.WithSystemdNotifier(systemd.notify)) require.NoError(t, err, "New should return no error") + if tc.quitBeforeServe { + d.Quit(ctx, tc.quitForcefully) + } + serveExit := make(chan error) go func() { - if tc.quitBeforeServe { - d.Quit(ctx, tc.quitForcefully) - } - - serveExit <- d.Serve() + serveExit <- d.Serve(&mockService{}) close(serveExit) }() @@ -237,24 +284,35 @@ // Wait for the server to start require.Eventually(t, func() bool { return systemd.readyNotifications.Load() > 0 - }, 10*time.Second, 100*time.Millisecond, "Systemd should have been notified") + }, 20*time.Second, 100*time.Millisecond, "Systemd should have been notified") - const wantState = "STATUS=Serving" + const wantState = "STATUS=Connected" require.Eventually(t, func() bool { return systemd.gotState.Load() == wantState - }, 60*time.Second, time.Second, "Systemd state should have been set to %q ", wantState) + }, 20*time.Second, time.Second, "Systemd state should have been set to %q ", wantState) require.False(t, systemd.gotUnsetEnvironment.Load(), "Unexpected value sent by Daemon to systemd notifier's unsetEnvironment") + + require.Eventually(t, agent.Service.AllConnected, 10*time.Second, 100*time.Millisecond, "Daemon never connected to agent's service") } d.Quit(ctx, tc.quitForcefully) + select { + case <-time.After(20 * time.Second): + require.Fail(t, "Serve should have exited after calling Quit") + case err = <-serveExit: + } + if tc.wantErr { - require.Error(t, <-serveExit, "Serve should have returned an error") + require.Error(t, err, "Serve should have returned an error") require.LessOrEqual(t, systemd.readyNotifications.Load(), int32(1), "Systemd notifier should have been notified at most once") return } - require.NoError(t, <-serveExit, "Serve should have returned no errors") + require.NoError(t, err, "Serve should have returned no errors") + + require.Eventually(t, func() bool { return !agent.Service.AnyConnected() }, + 10*time.Second, 100*time.Millisecond, "Service should have disconnected from the agent") require.Equal(t, int32(1), systemd.readyNotifications.Load(), "Systemd notifier should have been notified exactly once") require.False(t, systemd.gotUnsetEnvironment.Load(), "Unexpected value sent by Daemon to systemd notifier's unsetEnvironment") @@ -274,13 +332,14 @@ testCases := map[string]struct { firstConnectionSuccesful bool + firstConnectionLong bool }{ - "Success connecting after failing to connect": {}, - "Success connecting after previous connection dropped": {firstConnectionSuccesful: true}, + "Success connecting after failing to connect": {}, + "Success connecting after previous connection dropped": {firstConnectionSuccesful: true}, + "Success connecting after previous long-lived connection dropped": {firstConnectionLong: true, firstConnectionSuccesful: true}, } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -288,65 +347,50 @@ defer cancel() system, mock := testutils.MockSystem(t) + publicDir := mock.DefaultPublicDir() - portFile := mock.DefaultAddrFile() + systemd := &SystemdSdNotifierMock{returns: true} - registerer := func(ctx context.Context, ctrl wslinstanceservice.ControlStreamClient) *grpc.Server { - // No need for a real GRPC service - return grpc.NewServer() - } - - systemd := SystemdSdNotifierMock{returns: true} - - d, err := daemon.New(ctx, - registerer, - system, - daemon.WithSystemdNotifier(systemd.notify), - ) + d, err := daemon.New(ctx, system, daemon.WithSystemdNotifier(systemd.notify)) require.NoError(t, err, "New should return no error") defer d.Quit(ctx, true) - var server *grpc.Server - var agentData *testutils.MockAgentData + var agent *testutils.MockWindowsAgent if tc.firstConnectionSuccesful { - server, agentData = testutils.MockWindowsAgent(t, ctx, portFile) - defer server.Stop() + agent = testutils.NewMockWindowsAgent(t, ctx, publicDir) + defer agent.Stop() } //nolint:errcheck // We don't really care - go d.Serve() + go d.Serve(&mockService{}) const maxTimeout = 60 * time.Second if tc.firstConnectionSuccesful { require.Eventually(t, func() bool { - return systemd.gotState.Load() == "STATUS=Serving" - }, maxTimeout, time.Second, "Service should have set systemd state to Serving") + return systemd.gotState.Load() == "STATUS=Connected" + }, maxTimeout, time.Second, "Service should have set systemd state to Connected") - require.Equal(t, int32(1), agentData.ConnectionCount.Load(), "Service should have connected to the control stream") - server.Stop() + require.Eventually(t, agent.Service.AllConnected, 10*time.Second, 100*time.Millisecond, "Daemon never connected to agent's service") - // Avoid a race where the portfile is not removed until after the next server starts - require.Eventually(t, func() bool { - _, err := os.Stat(portFile) - return errors.Is(err, fs.ErrNotExist) - }, 20*time.Second, 100*time.Millisecond, "Stopping the Windows-Agent mock server should remove the port file") + if tc.firstConnectionLong { + // "Long-lived" means longer than a minute + time.Sleep(65 * time.Second) + } + + agent.Stop() } else { require.Eventually(t, func() bool { - return systemd.gotState.Load() == "STATUS=Not serving: waiting to retry" - }, maxTimeout, 100*time.Millisecond, "State should have been set to 'Not serving'") + return systemd.gotState.Load() == "STATUS=Not connected: waiting to retry" + }, maxTimeout, 100*time.Millisecond, "State should have been set to 'Not connected: waiting to retry'") } - server, agentData = testutils.MockWindowsAgent(t, ctx, portFile) - defer server.Stop() + agent = testutils.NewMockWindowsAgent(t, ctx, publicDir) + defer agent.Stop() - require.Eventually(t, func() bool { - return agentData.BackConnectionCount.Load() != 0 - }, time.Minute, time.Second, "Service should eventually connect to the agent") - - require.Equal(t, int32(1), systemd.readyNotifications.Load(), "Service should have notified systemd after connecting to the control stream") - require.Equal(t, int32(1), agentData.ConnectionCount.Load(), "Service should have connected to the control stream") + require.Eventually(t, agent.Service.AllConnected, 20*time.Second, 100*time.Millisecond, "Daemon never connected to agent's service") + require.EqualValues(t, 1, systemd.readyNotifications.Load(), "Service should have notified systemd after connecting to the control stream") }) } } @@ -390,6 +434,16 @@ return str } +type mockService struct{} + +func (s *mockService) ApplyProToken(ctx context.Context, msg *agentapi.ProAttachCmd) error { + return nil +} + +func (s *mockService) ApplyLandscapeConfig(ctx context.Context, msg *agentapi.LandscapeConfigCmd) error { + return nil +} + func TestWithProMock(t *testing.T) { testutils.ProMock(t) } func TestWithWslPathMock(t *testing.T) { testutils.WslPathMock(t) } func TestWithWslInfoMock(t *testing.T) { testutils.WslInfoMock(t) } diff -Nru wsl-pro-service-0.1.2build1/internal/streams/export_test.go wsl-pro-service-0.1.4/internal/streams/export_test.go --- wsl-pro-service-0.1.2build1/internal/streams/export_test.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/streams/export_test.go 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,19 @@ +package streams + +import ( + "context" + + "google.golang.org/grpc" +) + +// MultiClient represents a connected multiClient to the Windows Agent. +// It abstracts away the multiple streams into a single object. +// It only provides communication primitives, it does not handle the logic of the messages themselves. +type MultiClient = multiClient + +// connect connects to the three streams. Call Close to release resources. +// +//nolint:revive //False positive: MultiClient is public +func Connect(ctx context.Context, conn *grpc.ClientConn) (c *MultiClient, err error) { + return connect(ctx, conn) +} diff -Nru wsl-pro-service-0.1.2build1/internal/streams/multiclient.go wsl-pro-service-0.1.4/internal/streams/multiclient.go --- wsl-pro-service-0.1.2build1/internal/streams/multiclient.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/streams/multiclient.go 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,104 @@ +package streams + +import ( + "context" + "fmt" + + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + "google.golang.org/grpc" +) + +// multiClient represents a connected multiClient to the Windows Agent. +// It abstracts away the multiple streams into a single object. +// It only provides communication primitives, it does not handle the logic of the messages themselves. +type multiClient struct { + mainStream agentapi.WSLInstance_ConnectedClient + proStream agentapi.WSLInstance_ProAttachmentCommandsClient + lpeStream agentapi.WSLInstance_LandscapeConfigCommandsClient +} + +// connect connects to the three streams. Call Close to release resources. +func connect(ctx context.Context, conn *grpc.ClientConn) (c *multiClient, err error) { + client := agentapi.NewWSLInstanceClient(conn) + + mainStream, err := client.Connected(ctx) + if err != nil { + return nil, fmt.Errorf("could not connect to GRPC service: %v", err) + } + defer closeOnError(&err, mainStream) + + proStream, err := client.ProAttachmentCommands(ctx) + if err != nil { + return nil, fmt.Errorf("could not connect to Pro attachment stream: %v", err) + } + defer closeOnError(&err, proStream) + + lpeStream, err := client.LandscapeConfigCommands(ctx) + if err != nil { + return nil, fmt.Errorf("could not connect to Landscape config stream: %v", err) + } + defer closeOnError(&err, lpeStream) + + return &multiClient{ + mainStream: mainStream, + proStream: proStream, + lpeStream: lpeStream, + }, nil +} + +func closeOnError(err *error, closer interface{ CloseSend() error }) { + if *err != nil { + _ = closer.CloseSend() + } +} + +// SendInfo sends the distro info via the connected stream. +func (s *multiClient) SendInfo(info *agentapi.DistroInfo) error { + return s.mainStream.Send(info) +} + +// ProAttachStream is a getter for the ProAttachmentCmd stream. +func (s *multiClient) ProAttachStream() stream[agentapi.ProAttachCmd] { + return stream[agentapi.ProAttachCmd]{ + grpcStream: s.proStream, + } +} + +// LandscapeConfigStream is a getter for the LandscapeConfigCmd stream. +func (s *multiClient) LandscapeConfigStream() stream[agentapi.LandscapeConfigCmd] { + return stream[agentapi.LandscapeConfigCmd]{ + grpcStream: s.lpeStream, + } +} + +type grpcStream[Command any] interface { + Context() context.Context + Recv() (*Command, error) + Send(r *agentapi.MSG) error +} + +// stream provides a restricted interface for sending and receiving messages. +type stream[Command any] struct { + grpcStream[Command] +} + +func (s stream[Command]) SendResult(err error) error { + var errMsg string + if err != nil { + errMsg = err.Error() + } + + return s.grpcStream.Send(&agentapi.MSG{ + Data: &agentapi.MSG_Result{ + Result: errMsg, + }, + }) +} + +func (s stream[Command]) SendWslName(wslName string) error { + return s.grpcStream.Send(&agentapi.MSG{ + Data: &agentapi.MSG_WslName{ + WslName: wslName, + }, + }) +} diff -Nru wsl-pro-service-0.1.2build1/internal/streams/multiclient_test.go wsl-pro-service-0.1.4/internal/streams/multiclient_test.go --- wsl-pro-service-0.1.2build1/internal/streams/multiclient_test.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/streams/multiclient_test.go 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,256 @@ +package streams_test + +import ( + "context" + "errors" + "net" + "sync/atomic" + "testing" + "time" + + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/streams" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestConnect(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + dontServe bool + + wantErr bool + }{ + "Success": {}, + + "Error dialing an address that is not serving": {dontServe: true, wantErr: true}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var lc net.ListenConfig + lis, err := lc.Listen(ctx, "tcp", "localhost:0") + require.NoError(t, err, "Setup: could not listen") + defer lis.Close() + + service := &agentAPIServer{} + + if !tc.dontServe { + s := grpc.NewServer() + agentapi.RegisterWSLInstanceServer(s, service) + go func() { + err = s.Serve(lis) + if err != nil { + log.Warningf(ctx, "Serve error: %v", err) + } + }() + defer s.Stop() + } + + conn, err := grpc.DialContext(ctx, lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err, "Setup: Dial should have succeeded") + defer conn.Close() + + client, err := streams.Connect(ctx, conn) + if tc.wantErr { + require.Error(t, err, "Connect should return an error") + return + } + require.NoError(t, err, "Connect should not return an error") + + // Connection is immediate but updating the counts is not: hence the waits + require.Eventually(t, func() bool { return service.connected.callCount.Load() >= 1 }, + 5*time.Second, 100*time.Millisecond, "Should have connected to the Connected stream") + + require.Eventually(t, func() bool { return service.proattachment.callCount.Load() >= 1 }, + 5*time.Second, 100*time.Millisecond, "Should have connected to the Pro attachment stream") + + require.Eventually(t, func() bool { return service.landscapeConfig.callCount.Load() >= 1 }, + 5*time.Second, 100*time.Millisecond, "Should have connected to the Landscape configuration stream") + + require.NotNil(t, client.ProAttachStream(), "ProAttachStream should not return nil") + require.NotNil(t, client.LandscapeConfigStream(), "LandscapeConfigStream should not return nil") + }) + } +} + +func TestSendAndRecv(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var lc net.ListenConfig + lis, err := lc.Listen(ctx, "tcp", "localhost:0") + require.NoError(t, err, "Setup: could not listen") + defer lis.Close() + + service := &agentAPIServer{} + + s := grpc.NewServer() + agentapi.RegisterWSLInstanceServer(s, service) + go func() { + err = s.Serve(lis) + if err != nil { + log.Warningf(ctx, "Serve error: %v", err) + } + }() + defer s.Stop() + + conn, err := grpc.DialContext(ctx, lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err, "Setup: Dial should have succeeded") + defer conn.Close() + + client, err := streams.Connect(ctx, conn) + require.NoError(t, err, "Setup: Connect should not return an error") + + require.Eventually(t, func() bool { + connReady := service.connected.callCount.Load() > 0 + proReady := service.proattachment.callCount.Load() > 0 + lpeReady := service.landscapeConfig.callCount.Load() > 0 + return connReady && proReady && lpeReady + }, 10*time.Second, 100*time.Millisecond, "Setup: streams never connected") + + // Test sending messages Server->Client + err = service.SendProAttachmentCmd("token123") + require.NoError(t, err, "Sending commands should not fail") + + proMsg, err := client.ProAttachStream().Recv() + require.NoError(t, err, "ProAttachStream.Recv should not return error") + require.Equal(t, "token123", proMsg.GetToken(), "Mismatch between sent and received Pro token") + + err = service.SendLandscapeConfig("[client]\nhello=world", "uid1234") + require.NoError(t, err, "Sending commands should not fail") + + lpeMsg, err := client.LandscapeConfigStream().Recv() + require.NoError(t, err, "LandscapeConfigStream.Recv should not return error") + require.Equal(t, "[client]\nhello=world", lpeMsg.GetConfig(), "Mismatch between sent and received Landscape config") + require.Equal(t, "uid1234", lpeMsg.GetHostagentUid(), "Mismatch between sent and received Landscape hostagent UID") + + // Test sending messages Client->Server + err = client.SendInfo(&agentapi.DistroInfo{}) + require.NoError(t, err, "SendInfo should not return error") + require.Eventually(t, func() bool { return service.connected.recvCount.Load() >= 1 }, // We already received a message during the handshake + 5*time.Second, 100*time.Millisecond, "The server should have received a distro info message") + + err = client.ProAttachStream().SendResult(nil) + require.NoError(t, err, "ProAttachStream.SendResult should not return error") + require.Eventually(t, func() bool { return service.proattachment.recvCount.Load() >= 1 }, + 5*time.Second, 100*time.Millisecond, "The server should have received a result message via the Pro attachment stream") + + err = client.LandscapeConfigStream().SendResult(nil) + require.NoError(t, err, "LandscapeConfigStream.SendResult should not return error") + require.Eventually(t, func() bool { return service.landscapeConfig.recvCount.Load() >= 1 }, + 5*time.Second, 100*time.Millisecond, "The server should have received a result message via the Landscape stream") + + // Disconnect to exercise error cases + conn.Close() + + _, err = streams.Connect(ctx, conn) + require.Error(t, err, "Connect should return an error when using a closed connection") + + // Test sending messages after disconnecting + err = client.SendInfo(&agentapi.DistroInfo{}) + require.Error(t, err, "SendInfo should return an error after disconnecting") + + err = client.ProAttachStream().SendResult(nil) + require.Error(t, err, "ProAttachStream.SendResult should return an error after disconnecting") + + err = client.LandscapeConfigStream().SendResult(nil) + require.Error(t, err, "LandscapeConfigStream.SendResult should return an error after disconnecting") + + // Test receiving messages after disconnecting + _, err = client.ProAttachStream().Recv() + require.Error(t, err, "ProAttachStream.Recv should return an error after disconnecting") + + _, err = client.LandscapeConfigStream().Recv() + require.Error(t, err, "SendResult.Recv should return an error after disconnecting") +} + +type agentAPIServer struct { + agentapi.UnimplementedWSLInstanceServer + + connected stream + proattachment stream + landscapeConfig stream +} + +type stream struct { + callCount atomic.Uint32 + recvCount atomic.Uint32 + stream atomic.Value +} + +func (s *agentAPIServer) Connected(stream agentapi.WSLInstance_ConnectedServer) error { + s.connected.callCount.Add(1) + s.connected.stream.Store(stream) + + for { + _, err := stream.Recv() + if err != nil { + return nil + } + + s.connected.recvCount.Add(1) + } +} + +func (s *agentAPIServer) ProAttachmentCommands(stream agentapi.WSLInstance_ProAttachmentCommandsServer) error { + s.proattachment.callCount.Add(1) + s.proattachment.stream.Store(stream) + + for { + _, err := stream.Recv() + if err != nil { + return nil + } + + s.proattachment.recvCount.Add(1) + } +} + +func (s *agentAPIServer) LandscapeConfigCommands(stream agentapi.WSLInstance_LandscapeConfigCommandsServer) error { + s.landscapeConfig.callCount.Add(1) + s.landscapeConfig.stream.Store(stream) + + for { + _, err := stream.Recv() + if err != nil { + return nil + } + + s.landscapeConfig.recvCount.Add(1) + } +} + +func (s *agentAPIServer) SendProAttachmentCmd(token string) error { + stream := s.proattachment.stream.Load() + if stream == nil { + return errors.New("stream not connected") + } + + //nolint:forcetypeassert // This value is always this type (or nil, which we checked already) + return stream.(agentapi.WSLInstance_ProAttachmentCommandsServer).Send(&agentapi.ProAttachCmd{Token: token}) +} + +func (s *agentAPIServer) SendLandscapeConfig(config, hostagentUID string) error { + stream := s.landscapeConfig.stream.Load() + if stream == nil { + return errors.New("stream not connected") + } + + //nolint:forcetypeassert // This value is always this type (or nil, which we checked already) + return stream.(agentapi.WSLInstance_LandscapeConfigCommandsServer).Send(&agentapi.LandscapeConfigCmd{ + Config: config, + HostagentUid: hostagentUID, + }) +} diff -Nru wsl-pro-service-0.1.2build1/internal/streams/server.go wsl-pro-service-0.1.4/internal/streams/server.go --- wsl-pro-service-0.1.2build1/internal/streams/server.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/streams/server.go 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,263 @@ +// Package streams abstracts the bi-directional gRPC stream and provides a faux server that mimics a unary call server. +package streams + +import ( + "context" + "errors" + "fmt" + "io" + "reflect" + "sync" + + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" + "google.golang.org/grpc" +) + +// CommandService is the interface that the real service must implement to handle the commands received from the control stream. +type CommandService interface { + ApplyProToken(ctx context.Context, msg *agentapi.ProAttachCmd) error + ApplyLandscapeConfig(ctx context.Context, msg *agentapi.LandscapeConfigCmd) error +} + +// Server is a struct that mimics a unary call server. It is backed by a bi-directional gRPC stream. +// +// It is used to make unary calls from the real gRPC server (Windows Agent) to the real client (this faux server). +type Server struct { + conn *grpc.ClientConn + system *system.System + + done chan struct{} + + ctx context.Context + cancel context.CancelFunc + + gracefulCtx context.Context + gracefulCancel context.CancelFunc +} + +// SystemError is an error caused by a misconfiguration of the system, rather than +// originated from Ubuntu Pro for WSL. +type SystemError struct { + error +} + +// NewSystemError creates a new system error wrapping fmt.Errorf. +func NewSystemError(msg string, args ...any) SystemError { + return SystemError{fmt.Errorf(msg, args...)} +} + +func (err SystemError) Error() string { + return err.error.Error() +} + +// Is makes it so all SystemError match SystemError{}. +func (err SystemError) Is(e error) bool { + _, ok := e.(SystemError) + return ok +} + +// NewServer creates a new Server. +func NewServer(ctx context.Context, sys *system.System, conn *grpc.ClientConn) *Server { + ctx, cancel := context.WithCancel(ctx) + gCtx, gCancel := context.WithCancel(ctx) + + s := &Server{ + conn: conn, + system: sys, + done: make(chan struct{}), + + ctx: ctx, + cancel: cancel, + + gracefulCtx: gCtx, + gracefulCancel: gCancel, + } + + return s +} + +// Stop stops the server and the underlying connection immediately. +// It blocks until the server finishes its teardown. +func (s *Server) Stop() { + s.cancel() + <-s.done +} + +// GracefulStop stops the server as soon as all active unary calls finish. +// It blocks until the server finishes its teardown. +func (s *Server) GracefulStop() { + s.gracefulCancel() + <-s.done +} + +// Serve starts receiving commands from the control stream and forwards them to the provided service. +// It blocks until stops serving. +func (s *Server) Serve(service CommandService) error { + defer s.cancel() + defer close(s.done) + + client, err := connect(s.ctx, s.conn) + if err != nil { + return fmt.Errorf("could not start serving: could not connect: %v", err) + } + + ch := make(chan error) + var wg sync.WaitGroup + + for _, h := range []handler{ + newHandler(client.ProAttachStream(), service.ApplyProToken), + newHandler(client.LandscapeConfigStream(), service.ApplyLandscapeConfig), + } { + wg.Add(1) + go func() { + defer wg.Done() + ch <- h.run(s, client) + + // Gracefully stop other handlers once any of them exits. + s.gracefulCancel() + }() + } + + // Notify Agent that we are ready + info, err := s.system.Info(s.ctx) + if err != nil { + return NewSystemError("could not serve: %v", err) + } + + if err := client.SendInfo(info); err != nil { + return fmt.Errorf("could not serve: could not send first Connnected message: %v", err) + } + + if err := client.ProAttachStream().SendWslName(info.GetWslName()); err != nil { + return fmt.Errorf("could not serve: could not send first ProAttachCmd message: %v", err) + } + + if err := client.LandscapeConfigStream().SendWslName(info.GetWslName()); err != nil { + return fmt.Errorf("could not serve: could not send first LandscapeConfigCmd message: %v", err) + } + + log.Debug(s.ctx, "Server: sent preface messages to all streams") + + go func() { + wg.Wait() + close(ch) + }() + + err = nil + for msg := range ch { + err = errors.Join(err, msg) + } + if err != nil { + return fmt.Errorf("serve error: %w", err) + } + + return nil +} + +// handler interface for type erasure: it allows for having all handlerImpl in the same slice. +type handler interface { + run(s *Server, client *multiClient) error +} + +// newHandler takes the ingredients for a handler and hides their type under the type-erased handler. +// This is essentially a handler factory. +func newHandler[Command any](stream stream[Command], callback func(context.Context, *Command) error) handler { + return &handlingLoop[Command]{ + stream: stream, + callback: callback, + } +} + +// handlingLoop implements the logic of the request handling loop. +type handlingLoop[Command any] struct { + stream stream[Command] + callback func(context.Context, *Command) error +} + +func (h *handlingLoop[Command]) run(s *Server, client *multiClient) error { + // Use this context to log onto the stream, and to cancel with server.Stop + ctx, cancel := cancelWith(h.stream.Context(), s.ctx) + defer cancel() + + // Use this context to log onto the stream, but cancel with server.GracefulStop + gCtx, cancel := cancelWith(ctx, s.gracefulCtx) + defer cancel() + + for { + // Graceful stop + select { + case <-gCtx.Done(): + return nil + default: + } + + log.Debugf(ctx, "Started serving %s requests", reflect.TypeFor[Command]()) + + // Handle a single command + msg, ok, err := receiveWithContext(gCtx, h.stream.Recv) + if err != nil { + return fmt.Errorf("could not receive ProAttachCmd: %w", err) + } else if !ok { + // Non-erroneous exit. Probably a graceful stop. + return nil + } + + result := h.callback(ctx, msg) + + if err := h.stream.SendResult(result); err != nil { + return fmt.Errorf("could not send ProAttachCmd result: %w", err) + } + + // Send back updated info after command completion + info, err := s.system.Info(ctx) + if err != nil { + log.Warningf(ctx, "Streamserver: could not gather info after command completion: %v", err) + } + + if err = client.SendInfo(info); err != nil { + log.Warningf(ctx, "Streamserver: could not stream back info after command completion") + } + } +} + +// cancelWith creates a child context that is cancelled when with is done. +func cancelWith(ctx, with context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(ctx) + context.AfterFunc(with, cancel) + return ctx, cancel +} + +// Receive with context calls the recv receiver asyncronously. +// Returns (message, message error) if recv returned. +// Returns (nil, context error) if the context was cancelled. +func receiveWithContext[MessageT any](ctx context.Context, recv func() (*MessageT, error)) (*MessageT, bool, error) { + select { + case <-ctx.Done(): + return nil, false, ctx.Err() + default: + } + + type retval struct { + t *MessageT + err error + } + ch := make(chan retval) + + go func() { + defer close(ch) + t, err := recv() + ch <- retval{t, err} + }() + + select { + case <-ctx.Done(): + return nil, false, nil + case msg := <-ch: + if errors.Is(msg.err, io.EOF) { + return nil, false, nil + } + return msg.t, true, msg.err + } +} diff -Nru wsl-pro-service-0.1.2build1/internal/streams/server_test.go wsl-pro-service-0.1.4/internal/streams/server_test.go --- wsl-pro-service-0.1.2build1/internal/streams/server_test.go 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/streams/server_test.go 2024-04-19 05:32:36.000000000 +0000 @@ -0,0 +1,195 @@ +package streams_test + +import ( + "context" + "errors" + "testing" + "time" + + agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/streams" + "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func TestServe(t *testing.T) { + t.Parallel() + ctx := context.Background() + + sys, _ := testutils.MockSystem(t) + + agent := testutils.NewMockWindowsAgent(t, ctx, t.TempDir()) + defer agent.Stop() + + conn, err := grpc.DialContext(ctx, agent.Listener.Addr().String(), + grpc.WithTransportCredentials(agent.ClientCredentials)) + require.NoError(t, err, "Setup: could not Dial the mock windows agent") + defer conn.Close() + + server := streams.NewServer(ctx, sys, conn) + + service := &mockService{} + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(service) + close(errCh) + }() + + // Test handshake + + require.Eventually(t, func() bool { + return agent.Service.AllConnected() + }, 20*time.Second, 100*time.Millisecond, "Setup: Agent service never became ready") + + // Test receiving a pro token and returning success + err = agent.Service.ProAttachment.Send(&agentapi.ProAttachCmd{Token: "token345"}) + require.NoError(t, err, "Send should return no error") + + require.Eventually(t, func() bool { + return len(agent.Service.ProAttachment.History()) > 1 + }, 20*time.Second, 100*time.Millisecond, "Server did not send a response to the Pro attach command") + require.Empty(t, agent.Service.ProAttachment.History()[1].GetResult(), "ProAttachment should return a successful result") + + // Test receiving a pro token and returning error + + err = agent.Service.ProAttachment.Send(&agentapi.ProAttachCmd{Token: "HARDCODED_FAILURE"}) + require.NoError(t, err, "Send should return no error") + + require.Eventually(t, func() bool { + return len(agent.Service.ProAttachment.History()) > 2 + }, 20*time.Second, 100*time.Millisecond, "Server did not send a response to the Pro attach command") + require.NotEmpty(t, agent.Service.ProAttachment.History()[2].GetResult(), "ProAttachment should return an error result") + + // Test receiving a Landscape config and returning success + + err = agent.Service.LandscapeConfig.Send(&agentapi.LandscapeConfigCmd{Config: "hello=world"}) + require.NoError(t, err, "Send should return no error") + + require.Eventually(t, func() bool { + return len(agent.Service.LandscapeConfig.History()) > 1 + }, 20*time.Second, 100*time.Millisecond, "Server did not send a response to the Pro attach command") + require.Empty(t, agent.Service.LandscapeConfig.History()[1].GetResult(), "LandscapeConfig should return a successful result") + + // Test receiving a Landscape config and returning error + + err = agent.Service.LandscapeConfig.Send(&agentapi.LandscapeConfigCmd{Config: "HARDCODED_FAILURE"}) + require.NoError(t, err, "Send should return no error") + + require.Eventually(t, func() bool { + return len(agent.Service.LandscapeConfig.History()) > 2 + }, 20*time.Second, 100*time.Millisecond, "Server did not send a response to the Pro attach command") + require.NotEmpty(t, agent.Service.LandscapeConfig.History()[2].GetResult(), "LandscapeConfig should return an error result") + + server.GracefulStop() + select { + case err := <-errCh: + require.NoError(t, err, "Serve should not return an error when gracefully stopped") + case <-time.After(10 * time.Second): + require.Fail(t, "GracefulStop should interrupt Serve") + } +} + +func TestStop(t *testing.T) { + t.Parallel() + ctx := context.Background() + + sys, _ := testutils.MockSystem(t) + + agent := testutils.NewMockWindowsAgent(t, ctx, t.TempDir()) + defer agent.Stop() + + conn, err := grpc.DialContext(ctx, agent.Listener.Addr().String(), + grpc.WithTransportCredentials(agent.ClientCredentials)) + require.NoError(t, err, "Setup: could not Dial the mock windows agent") + defer conn.Close() + + server := streams.NewServer(ctx, sys, conn) + + service := &mockService{} + errCh := make(chan error) + go func() { + errCh <- server.Serve(service) + close(errCh) + }() + + require.Eventually(t, func() bool { + return agent.Service.AllConnected() + }, 20*time.Second, 100*time.Millisecond, "Setup: Agent service never became ready") + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + service.setBlocking(ctx) + + err = agent.Service.ProAttachment.Send(&agentapi.ProAttachCmd{}) + require.NoError(t, err, "mock agent could not send a pro-attach command") + + err = agent.Service.LandscapeConfig.Send(&agentapi.LandscapeConfigCmd{}) + require.NoError(t, err, "mock agent could not send a landscape-config command") + + // Wait for unary calls to be made + time.Sleep(10 * time.Second) + + server.Stop() + select { + case err := <-errCh: + require.Error(t, err, "Stop should have interrupted the unary calls") + case <-time.After(10 * time.Second): + require.Fail(t, "Stop should interrupt Serve") + } +} + +type mockService struct { + blockingCalls bool + + ctx context.Context +} + +func (s *mockService) setBlocking(ctx context.Context) { + s.blockingCalls = true + s.ctx = ctx +} + +func (s *mockService) ApplyProToken(ctx context.Context, msg *agentapi.ProAttachCmd) error { + if msg.GetToken() == "HARDCODED_FAILURE" { + return errors.New("mock error") + } + + // Mock a slow task that can be cancelled + if s.blockingCalls { + select { + case <-ctx.Done(): + // Mock task interrupted + return ctx.Err() + case <-s.ctx.Done(): + // Mock task completed successfully + } + } + + return nil +} + +func (s *mockService) ApplyLandscapeConfig(ctx context.Context, msg *agentapi.LandscapeConfigCmd) error { + if msg.GetConfig() == "HARDCODED_FAILURE" { + return errors.New("mock error") + } + + // Mock a slow task that can be cancelled + if s.blockingCalls { + select { + case <-ctx.Done(): + // Mock task interrupted + return ctx.Err() + case <-s.ctx.Done(): + // Mock task completed successfully + } + } + + return nil +} + +func TestWithProMock(t *testing.T) { testutils.ProMock(t) } +func TestWithWslPathMock(t *testing.T) { testutils.WslPathMock(t) } +func TestWithWslInfoMock(t *testing.T) { testutils.WslInfoMock(t) } +func TestWithCmdExeMock(t *testing.T) { testutils.CmdExeMock(t) } diff -Nru wsl-pro-service-0.1.2build1/internal/system/backend.go wsl-pro-service-0.1.4/internal/system/backend.go --- wsl-pro-service-0.1.2build1/internal/system/backend.go 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/backend.go 2024-04-03 11:34:21.000000000 +0000 @@ -1,7 +1,10 @@ package system import ( + "context" "os" + "os/exec" + "os/user" "path/filepath" ) @@ -22,24 +25,33 @@ } // ProExecutable returns the full command to run the pro executable with the provided arguments. -func (b realBackend) ProExecutable(args ...string) (string, []string) { - return "pro", args +func (b realBackend) ProExecutable(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "pro", args...) } -func (b realBackend) LandscapeConfigExecutable(args ...string) (string, []string) { - return "landscape-config", args +func (b realBackend) LandscapeConfigExecutable(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "landscape-config", args...) } // ProExecutable returns the full command to run the wslpath executable with the provided arguments. -func (b realBackend) WslpathExecutable(args ...string) (string, []string) { - return "wslpath", args +func (b realBackend) WslpathExecutable(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "wslpath", args...) } // WslinfoExecutable returns the full command to run the wslinfo executable with the provided arguments. -func (b realBackend) WslinfoExecutable(args ...string) (string, []string) { - return "wslinfo", args +func (b realBackend) WslinfoExecutable(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "wslinfo", args...) } -func (b realBackend) CmdExe(path string, args ...string) (string, []string) { - return path, args +func (b realBackend) CmdExe(ctx context.Context, path string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, path, args...) + + // cmd.exe must run within the Windows filesystem to avoid warnings. + cmd.Dir = filepath.Dir(path) + + return cmd +} + +func (b realBackend) LookupGroup(name string) (*user.Group, error) { + return user.LookupGroup("landscape") } diff -Nru wsl-pro-service-0.1.2build1/internal/system/export_test.go wsl-pro-service-0.1.4/internal/system/export_test.go --- wsl-pro-service-0.1.2build1/internal/system/export_test.go 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/export_test.go 2024-03-15 11:59:36.000000000 +0000 @@ -5,3 +5,5 @@ func (s *System) CmdExeCache() *string { return &s.cmdExe } + +type RealBackend = realBackend diff -Nru wsl-pro-service-0.1.2build1/internal/system/landscape.go wsl-pro-service-0.1.4/internal/system/landscape.go --- wsl-pro-service-0.1.2build1/internal/system/landscape.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/landscape.go 2024-04-03 11:34:21.000000000 +0000 @@ -5,7 +5,6 @@ "context" "fmt" "os" - "os/exec" "path/filepath" "strings" @@ -31,10 +30,9 @@ return err } - exe, args := s.backend.LandscapeConfigExecutable("--config", landscapeConfigPath, "--silent") - //nolint:gosec // In production code, these variables are hard-coded. - if out, err := exec.CommandContext(ctx, exe, args...).Output(); err != nil { - return fmt.Errorf("%s returned an error: %v. Output: %s", exe, err, strings.TrimSpace(string(out))) + cmd := s.backend.LandscapeConfigExecutable(ctx, "--config", landscapeConfigPath, "--silent") + if _, err := runCommand(cmd); err != nil { + return fmt.Errorf("could not enable Landscape: %v", err) } return nil @@ -42,11 +40,9 @@ // LandscapeDisable unregisters the current distro from Landscape. func (s *System) LandscapeDisable(ctx context.Context) (err error) { - exe, args := s.backend.LandscapeConfigExecutable("--disable") - - //nolint:gosec // In production code, these variables are hard-coded (except for the URLs). - if out, err := exec.CommandContext(ctx, exe, args...).Output(); err != nil { - return fmt.Errorf("could not disable Landscape: %s returned an error: %v\nOutput:%s", exe, err, string(out)) + cmd := s.backend.LandscapeConfigExecutable(ctx, "--disable") + if _, err := runCommand(cmd); err != nil { + return fmt.Errorf("could not disable Landscape:%v", err) } return nil @@ -55,6 +51,16 @@ func (s *System) writeConfig(landscapeConfig string) (err error) { defer decorate.OnError(&err, "could not write Landscape configuration") + userID, err := s.currentUser() + if err != nil { + return err + } + + groupID, err := s.groupToGUID("landscape") + if err != nil { + return err + } + tmp := s.backend.Path(landscapeConfigPath + ".new") final := s.backend.Path(landscapeConfigPath) @@ -62,11 +68,16 @@ return fmt.Errorf("could not create config directory: %v", err) } - //nolint:gosec // Needs 0604 for the Landscape client to be able to read it - if err = os.WriteFile(tmp, []byte(landscapeConfig), 0604); err != nil { + //nolint:gosec // Needs 0640 for the landscape client to be able to read it. + if err := os.WriteFile(tmp, []byte(landscapeConfig), 0640); err != nil { return fmt.Errorf("could not write to file: %v", err) } + if err := os.Chown(tmp, userID, groupID); err != nil { + _ = os.RemoveAll(tmp) + return fmt.Errorf("could not change ownership to landscape group: %v", err) + } + if err := os.Rename(tmp, final); err != nil { _ = os.RemoveAll(tmp) return err @@ -93,11 +104,15 @@ if err != nil { return "", err } - if err := overrideKey(ctx, data, "client", "computer_title", distroName); err != nil { + if err := createKey(ctx, data, "client", "computer_title", distroName, true); err != nil { + return "", err + } + + if err := createKey(ctx, data, "client", "hostagent_uid", hostagentUID, true); err != nil { return "", err } - if err := overrideKey(ctx, data, "client", "hostagent_uid", hostagentUID); err != nil { + if err := createKey(ctx, data, "client", "tags", "wsl", false); err != nil { return "", err } @@ -113,8 +128,8 @@ return w.String(), nil } -// overrideKey sets a key to a particular value. -func overrideKey(ctx context.Context, data *ini.File, section, key, value string) error { +// createKey tries to create a key with a particular value, optionally overriding an existing key. +func createKey(ctx context.Context, data *ini.File, section, key, value string, override bool) error { sec, err := data.GetSection(section) if err != nil { if sec, err = data.NewSection(section); err != nil { @@ -123,7 +138,12 @@ } if sec.HasKey(key) { - log.Infof(ctx, "Landscape config contains key %q. Its value will be overridden with %s", key, value) + if !override { + log.Infof(ctx, "Landscape config contains key %q. Its value will not be overridden.", key) + return nil + } + + log.Infof(ctx, "Landscape config contains key %q. Its value will be overridden.", key) sec.DeleteKey(key) } @@ -154,11 +174,10 @@ pathWindows := k.String() - cmd, args := s.backend.WslpathExecutable("-ua", pathWindows) - //nolint:gosec // In production code, the executable (wslpath) is hardcoded. - out, err := exec.CommandContext(ctx, cmd, args...).CombinedOutput() + cmd := s.backend.WslpathExecutable(ctx, "-ua", pathWindows) + out, err := runCommand(cmd) if err != nil { - return fmt.Errorf("could not translate SSL certificate path %q to a WSL path: %v: %s", pathWindows, err, out) + return fmt.Errorf("could not translate SSL certificate path %q to a WSL path: %v", pathWindows, err) } pathLinux := s.Path(strings.TrimSpace(string(out))) diff -Nru wsl-pro-service-0.1.2build1/internal/system/networking.go wsl-pro-service-0.1.4/internal/system/networking.go --- wsl-pro-service-0.1.2build1/internal/system/networking.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/networking.go 2024-03-15 11:59:36.000000000 +0000 @@ -8,7 +8,6 @@ "fmt" "net" "os" - "os/exec" "strconv" "strings" @@ -41,12 +40,11 @@ } func (s *System) networkingMode(ctx context.Context) (string, error) { - exe, argv := s.backend.WslinfoExecutable("--networking-mode", "-n") + cmd := s.backend.WslinfoExecutable(ctx, "--networking-mode", "-n") - //nolint:gosec // In production code, these variables are hard-coded. - out, err := exec.CommandContext(ctx, exe, argv...).CombinedOutput() + out, err := runCommand(cmd) if err != nil { - return "", fmt.Errorf("failed call to wslinfo: %v. Output: %s", err, out) + return "", err } return strings.TrimSpace(string(out)), nil diff -Nru wsl-pro-service-0.1.2build1/internal/system/pro.go wsl-pro-service-0.1.4/internal/system/pro.go --- wsl-pro-service-0.1.2build1/internal/system/pro.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/pro.go 2024-03-15 11:59:36.000000000 +0000 @@ -4,7 +4,6 @@ "context" "encoding/json" "fmt" - "os/exec" "github.com/ubuntu/decorate" ) @@ -13,18 +12,17 @@ func (s System) ProStatus(ctx context.Context) (attached bool, err error) { defer decorate.OnError(&err, "pro status") - exe, args := s.backend.ProExecutable("status", "--format=json") - //nolint:gosec // In production code, these variables are hard-coded (except for the token). - out, err := exec.CommandContext(ctx, exe, args...).Output() + cmd := s.backend.ProExecutable(ctx, "status", "--format=json") + out, err := runCommand(cmd) if err != nil { - return false, fmt.Errorf("command returned error: %v\nStdout:%s", err, string(out)) + return false, err } var attachedStatus struct { Attached bool } if err = json.Unmarshal(out, &attachedStatus); err != nil { - return false, fmt.Errorf("could not parse output: %v\nOutput: %s", err, string(out)) + return false, fmt.Errorf("could not parse output: %v. Output: %s", err, string(out)) } return attachedStatus.Attached, nil @@ -41,11 +39,9 @@ {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "success", "warnings": []} */ - exe, args := s.backend.ProExecutable("attach", token, "--format=json") - //nolint:gosec // In production code, these variables are hard-coded (except for the token). - out, err := exec.CommandContext(ctx, exe, args...).Output() - if err != nil { - return fmt.Errorf("command returned error: %v\nOutput:%s", err, string(out)) + cmd := s.backend.ProExecutable(ctx, "attach", token, "--format=json") + if _, err := runCommand(cmd); err != nil { + return err } return nil @@ -56,9 +52,8 @@ func (s *System) ProDetach(ctx context.Context) (err error) { defer decorate.OnError(&err, "pro detach") - exe, args := s.backend.ProExecutable("detach", "--assume-yes", "--format=json") - //nolint:gosec // In production code, these variables are hard-coded (except for the token). - out, detachErr := exec.CommandContext(ctx, exe, args...).Output() + cmd := s.backend.ProExecutable(ctx, "detach", "--assume-yes", "--format=json") + out, detachErr := runCommand(cmd) if detachErr != nil { // check that the error is not that the machine is already detached var detachedError struct { @@ -72,7 +67,7 @@ } if len(detachedError.Errors) == 0 { - return fmt.Errorf("command returned error: %v.\nOutput: %s", detachErr, string(out)) + return detachErr } if detachedError.Errors[0].MessageCode == "unattached" { diff -Nru wsl-pro-service-0.1.2build1/internal/system/system.go wsl-pro-service-0.1.4/internal/system/system.go --- wsl-pro-service-0.1.2build1/internal/system/system.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/system.go 2024-04-03 11:34:21.000000000 +0000 @@ -4,10 +4,14 @@ import ( "bufio" + "bytes" "context" + "errors" "fmt" "os" "os/exec" + "os/user" + "strconv" "strings" agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" @@ -33,11 +37,14 @@ Path(p ...string) string Hostname() (string, error) GetenvWslDistroName() string - ProExecutable(args ...string) (string, []string) - LandscapeConfigExecutable(args ...string) (string, []string) - WslpathExecutable(args ...string) (string, []string) - WslinfoExecutable(args ...string) (string, []string) - CmdExe(path string, args ...string) (string, []string) + LookupGroup(string) (*user.Group, error) + + ProExecutable(ctx context.Context, args ...string) *exec.Cmd + LandscapeConfigExecutable(ctx context.Context, args ...string) *exec.Cmd + WslpathExecutable(ctx context.Context, args ...string) *exec.Cmd + WslinfoExecutable(ctx context.Context, args ...string) *exec.Cmd + + CmdExe(ctx context.Context, path string, args ...string) *exec.Cmd } type options struct { @@ -57,13 +64,13 @@ // New instantiates a stateless object that mediates interactions with the filesystem // as well as a few key executables. -func New(args ...Option) System { +func New(args ...Option) *System { opts := options{backend: realBackend{}} for _, f := range args { f(&opts) } - return System{ + return &System{ backend: opts.backend, } } @@ -142,11 +149,10 @@ return env, nil } - exe, args := s.backend.WslpathExecutable("-w", "/") - //nolint:gosec //outside of tests, this function simply prepends "wslpath" to the args. - out, err := exec.CommandContext(ctx, exe, args...).Output() + cmd := s.backend.WslpathExecutable(ctx, "-w", "/") + out, err := runCommand(cmd) if err != nil { - return "", fmt.Errorf("could not get distro root path: %v. Stdout: %s", err, string(out)) + return "", fmt.Errorf("could not get distro root path: %v. Output: %s", err, string(out)) } // Example output for Windows 11: "\\wsl.localhost\Ubuntu-Preview\" @@ -171,26 +177,22 @@ return wslPath, err } - exe, args := s.backend.CmdExe(cmdExe, "/C", "echo %UserProfile%") - //nolint:gosec //this function simply prepends the WSL path to "cmd.exe" to the args. - out, err := exec.CommandContext(ctx, exe, args...).Output() + cmd := s.backend.CmdExe(ctx, cmdExe, "/C", "echo %UserProfile%") + winHome, err := runCommand(cmd) if err != nil { - return wslPath, fmt.Errorf("%s: error: %v, stdout: %s", exe, err, string(out)) + return wslPath, err } - // Path from Windows' perspective ( C:\Users\... ) + // We have the path from Windows' perspective ( C:\Users\... ) // It must be converted to linux ( /mnt/c/Users/... ) - winHome := strings.TrimSpace(string(out)) - exe, args = s.backend.WslpathExecutable("-ua", winHome) - //nolint:gosec //outside of tests, this function simply prepends "wslpath" to the args. - out, err = exec.CommandContext(ctx, exe, args...).Output() + cmd = s.backend.WslpathExecutable(ctx, "-ua", string(winHome)) + winHomeLinux, err := runCommand(cmd) if err != nil { - return wslPath, fmt.Errorf("%s: error: %v, stdout: %s", exe, err, string(out)) + return wslPath, err } - winHomeLinux := strings.TrimSpace(string(out)) - wslPath = s.Path(winHomeLinux) + wslPath = s.Path(string(winHomeLinux)) // wslpath can return invalid paths, so we make sure that it exists if s, err := os.Stat(wslPath); err != nil { @@ -203,6 +205,24 @@ return wslPath, nil } +// runCommand is a helper that runs a command and returns stdout. +// The first return value is the always trimmed stdout, even in case of error. +// In case of error, both Stdout and Stderr are included in the error message. +func runCommand(cmd *exec.Cmd) ([]byte, error) { + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + out := bytes.TrimSpace(stdout.Bytes()) + if err != nil { + return out, fmt.Errorf("%s: error: %v.\n Stdout: %s\n Stderr: %s", cmd.Path, err, out, stderr.String()) + } + + return out, nil +} + // Path converts an absolute path into one inside the mocked filesystem. func (s System) Path(path ...string) string { return s.backend.Path(path...) @@ -258,3 +278,33 @@ return "", fmt.Errorf("none of the mounted drives contains subpath %s", subPath) } + +// groupToGUID searches the group with the specified name and returns its GID. +func (s *System) groupToGUID(name string) (int, error) { + group, err := s.backend.LookupGroup(name) + if err != nil { + return 0, err + } + + guid, err := strconv.ParseInt(group.Gid, 10, 32) + if err != nil { + return 0, errors.New("could not parse %s as an integer") + } + + return int(guid), nil +} + +// currentUser returns the UID of the current user. +func (s *System) currentUser() (int, error) { + user, err := user.Current() + if err != nil { + return 0, err + } + + userID, err := strconv.ParseInt(user.Uid, 10, 32) + if err != nil { + return 0, errors.New("could not parse %s as an integer") + } + + return int(userID), nil +} diff -Nru wsl-pro-service-0.1.2build1/internal/system/system_test.go wsl-pro-service-0.1.4/internal/system/system_test.go --- wsl-pro-service-0.1.2build1/internal/system/system_test.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/system_test.go 2024-04-03 11:34:21.000000000 +0000 @@ -7,7 +7,6 @@ "strings" "testing" - "github.com/canonical/ubuntu-pro-for-wsl/common/golden" commontestutils "github.com/canonical/ubuntu-pro-for-wsl/common/testutils" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/system" "github.com/canonical/ubuntu-pro-for-wsl/wsl-pro-service/internal/testutils" @@ -50,7 +49,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -125,7 +123,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -191,7 +188,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -238,7 +234,7 @@ func overrideProcMount(t *testing.T, mock *testutils.SystemMock) { t.Helper() - procMount := filepath.Join(golden.TestFixturePath(t), "proc/mounts") + procMount := filepath.Join(commontestutils.TestFixturePath(t), "proc/mounts") if _, err := os.Stat(procMount); err != nil { require.ErrorIsf(t, err, os.ErrNotExist, "Setup: could not stat %q", procMount) @@ -274,7 +270,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -318,7 +313,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -363,7 +357,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -400,21 +393,24 @@ breakWriteConfig bool breakLandscapeConfig bool breakWSLPath bool + noLandscapeGroup bool wantErr bool }{ - "Success": {}, - "Success overriding computer_title": {}, - "Success overriding the SSL certficate path": {}, + "Success": {}, + "Success overriding computer_title": {}, + "Success overriding the SSL certficate path": {}, + "Do not append wsl tag when config tag is provided": {}, + "Do not append wsl tag when config tag is provided but empty": {}, "Error when the file cannot be parsed": {wantErr: true}, "Error when the config file cannot be written": {breakWriteConfig: true, wantErr: true}, "Error when the landscape-config command fails": {breakLandscapeConfig: true, wantErr: true}, "Error when failing to override the SSL certficate path": {breakWSLPath: true, wantErr: true}, + "Error when the Landscape user does not exist": {noLandscapeGroup: true, wantErr: true}, } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -434,7 +430,11 @@ mock.SetControlArg(testutils.WslpathErr) } - config, err := os.ReadFile(filepath.Join(golden.TestFixturePath(t), "landscape.conf")) + if tc.noLandscapeGroup { + mock.LandscapeGroupGID = "" + } + + config, err := os.ReadFile(filepath.Join(commontestutils.TestFixturePath(t), "landscape.conf")) require.NoError(t, err, "Setup: could not load fixture") err = s.LandscapeEnable(ctx, string(config), "landscapeUID1234") @@ -453,7 +453,7 @@ // runs, so the golden file would never match. This is the solution: got := strings.ReplaceAll(string(out), mock.FsRoot, "${FILESYSTEM_ROOT}") - want := golden.LoadWithUpdateFromGolden(t, got) + want := commontestutils.LoadWithUpdateFromGolden(t, got) require.Equal(t, want, got, "Landscape executable did not receive the right config") }) } @@ -534,7 +534,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -548,8 +547,8 @@ mock.SetControlArg(testutils.WslInfoIsNAT) } - copyFile(t, tc.etcResolv, filepath.Join(golden.TestFamilyPath(t), "etc-resolv.conf"), mock.Path("/etc/resolv.conf")) - copyFile(t, tc.procNetRoute, filepath.Join(golden.TestFamilyPath(t), "proc-net-route"), mock.Path("/proc/net/route")) + copyFile(t, tc.etcResolv, filepath.Join(commontestutils.TestFamilyPath(t), "etc-resolv.conf"), mock.Path("/etc/resolv.conf")) + copyFile(t, tc.procNetRoute, filepath.Join(commontestutils.TestFamilyPath(t), "proc-net-route"), mock.Path("/proc/net/route")) got, err := sys.WindowsHostAddress(ctx) if tc.wantErr { @@ -576,7 +575,6 @@ } for name, tc := range testCases { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() @@ -599,6 +597,46 @@ } } +func TestRealBackend(t *testing.T) { + t.Parallel() + + ctx := context.Background() + b := system.RealBackend{} + + // Asserting generated commands + // + // Note that we cannot test the cmd.Path directly as it will depend on the install location, + // so we only test the base of the path. + + pro := b.ProExecutable(ctx, "arg1", "arg2") + assertBasePath(t, "pro", pro.Path, "ProExecutable did not return the expected command") + assert.Equal(t, []string{"pro", "arg1", "arg2"}, pro.Args, "ProExecutable did not return the expected arguments") + + lpe := b.LandscapeConfigExecutable(ctx, "arg1", "arg2") + assertBasePath(t, "landscape-config", lpe.Path, "LandscapeConfigExecutable did not return the expected command") + assert.Equal(t, []string{"landscape-config", "arg1", "arg2"}, lpe.Args, "LandscapeConfigExecutable did not return the expected arguments") + + wpath := b.WslpathExecutable(ctx, "arg1", "arg2") + assertBasePath(t, "wslpath", wpath.Path, "WslpathExecutable did not return the expected command") + assert.Equal(t, []string{"wslpath", "arg1", "arg2"}, wpath.Args, "WslpathExecutable did not return the expected arguments") + + winfo := b.WslinfoExecutable(ctx, "arg1", "arg2") + assertBasePath(t, "wslinfo", winfo.Path, "WslinfoExecutable did not return the expected command") + assert.Equal(t, []string{"wslinfo", "arg1", "arg2"}, winfo.Args, "WslinfoExecutable did not return the expected arguments") + + cmd := b.CmdExe(ctx, "/mnt/c/WINDOWS/whatever/cmd.exe", "arg1", "arg2") + assert.Equal(t, "/mnt/c/WINDOWS/whatever", cmd.Dir, "CmdExe did not set the expected directory") + assert.Equal(t, "/mnt/c/WINDOWS/whatever/cmd.exe", cmd.Path, "CmdExe did not return the expected command") + assert.Equal(t, []string{"/mnt/c/WINDOWS/whatever/cmd.exe", "arg1", "arg2"}, cmd.Args, "CmdExe did not return the expected arguments") +} + +// Asserts that the base of got is equal to wantBase, and if not, it fails the test with a message. +func assertBasePath(t *testing.T, wantBase, got, msg string) { + t.Helper() + base := filepath.Base(got) + assert.Equalf(t, wantBase, base, "Mismatch in base path.\n%s", msg) +} + func TestWithProMock(t *testing.T) { testutils.ProMock(t) } func TestWithLandscapeConfigMock(t *testing.T) { testutils.LandscapeConfigMock(t) } func TestWithWslPathMock(t *testing.T) { testutils.WslPathMock(t) } diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided/landscape.conf wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided/landscape.conf --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided/landscape.conf 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided/landscape.conf 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,6 @@ +[host] +url = www.example.com + +[client] +hello = world +tags = servers, oneiric, database, production diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty/landscape.conf wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty/landscape.conf --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty/landscape.conf 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty/landscape.conf 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,6 @@ +[host] +url = www.example.com + +[client] +hello = world +tags = diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/error_when_the_landscape_user_does_not_exist/landscape.conf wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/error_when_the_landscape_user_does_not_exist/landscape.conf --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/error_when_the_landscape_user_does_not_exist/landscape.conf 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/error_when_the_landscape_user_does_not_exist/landscape.conf 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,5 @@ +[host] +url = www.example.com + +[client] +hello = world \ No newline at end of file diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,5 @@ +[client] +hello = world +tags = servers, oneiric, database, production +computer_title = TEST_DISTRO +hostagent_uid = landscapeUID1234 diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty 1970-01-01 00:00:00.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/do_not_append_wsl_tag_when_config_tag_is_provided_but_empty 2024-04-03 11:34:21.000000000 +0000 @@ -0,0 +1,5 @@ +[client] +hello = world +tags = +computer_title = TEST_DISTRO +hostagent_uid = landscapeUID1234 diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success 2024-04-03 11:34:21.000000000 +0000 @@ -2,3 +2,4 @@ hello = world computer_title = TEST_DISTRO hostagent_uid = landscapeUID1234 +tags = wsl diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success_despite_failing_to_override_the_ssl_certficate_path wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success_despite_failing_to_override_the_ssl_certficate_path --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success_despite_failing_to_override_the_ssl_certficate_path 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success_despite_failing_to_override_the_ssl_certficate_path 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -[client] -hello = world -ssl_public_key = D:\Users\TestUser\certificate -computer_title = TEST_DISTRO -hostagent_uid = landscapeUID1234 diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_computer_title wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_computer_title --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_computer_title 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_computer_title 2024-04-03 11:34:21.000000000 +0000 @@ -2,3 +2,4 @@ hello = world computer_title = TEST_DISTRO hostagent_uid = landscapeUID1234 +tags = wsl diff -Nru wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_the_ssl_certficate_path wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_the_ssl_certficate_path --- wsl-pro-service-0.1.2build1/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_the_ssl_certficate_path 2024-01-30 12:21:46.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/system/testdata/TestLandscapeEnable/golden/success_overriding_the_ssl_certficate_path 2024-04-03 11:34:21.000000000 +0000 @@ -3,3 +3,4 @@ ssl_public_key = ${FILESYSTEM_ROOT}/mnt/d/Users/TestUser/certificate computer_title = TEST_DISTRO hostagent_uid = landscapeUID1234 +tags = wsl diff -Nru wsl-pro-service-0.1.2build1/internal/testutils/mock_agent.go wsl-pro-service-0.1.4/internal/testutils/mock_agent.go --- wsl-pro-service-0.1.2build1/internal/testutils/mock_agent.go 2024-02-28 10:02:18.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/testutils/mock_agent.go 2024-04-19 05:32:36.000000000 +0000 @@ -2,222 +2,336 @@ import ( "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" "fmt" + "io" + "math/big" "net" "os" - "sync/atomic" + "path/filepath" + "sync" "testing" - "time" agentapi "github.com/canonical/ubuntu-pro-for-wsl/agentapi/go" + "github.com/canonical/ubuntu-pro-for-wsl/common" + "github.com/canonical/ubuntu-pro-for-wsl/common/certs" log "github.com/canonical/ubuntu-pro-for-wsl/common/grpc/logstreamer" "github.com/stretchr/testify/require" + "github.com/ubuntu/decorate" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" ) -// This file deals with mocking the Windows Agent, and introducing errors -// when necessary. +// MockWindowsAgent mocks the windows agent server. +type MockWindowsAgent struct { + Server *grpc.Server + Service *mockWSLInstanceService + Listener net.Listener -type options struct { - sendBadPort bool - dropStreamBeforeSendingPort bool - dropStreamBeforeFirstRecv bool -} - -// AgentOption is used for optional arguments in New. -type AgentOption func(*options) - -// WithSendBadPort orders the WslInstance service mock to send port :0, -// which is not a valid port to-presect. -func WithSendBadPort() AgentOption { - return func(o *options) { - o.sendBadPort = true - } -} + ClientCredentials credentials.TransportCredentials -// WithDropStreamBeforeReceivingInfo orders the WslInstance service mock -// to drop the connection before receiving the first info. -func WithDropStreamBeforeReceivingInfo() AgentOption { - return func(o *options) { - o.dropStreamBeforeFirstRecv = true - } -} - -// WithDropStreamBeforeSendingPort orders the WslInstance service mock -// to drop the connection before sending the port. -func WithDropStreamBeforeSendingPort() AgentOption { - return func(o *options) { - o.dropStreamBeforeSendingPort = true - } + Started chan struct{} + Stopped chan struct{} } // MockWindowsAgent mocks the windows-agent. It starts a GRPC service that will perform // the port dance and stay connected. It'll write the port file as well. +// For simplicity's sake, it only suports one WSL distro at a time. // -// You can stop the server manually, otherwise it'll stop during cleanup. +// You can stop it manually, otherwise it'll stop during cleanup. // //nolint:revive // testing.T should go before context, regardless of what these linters say. -func MockWindowsAgent(t *testing.T, ctx context.Context, addrFile string, args ...AgentOption) (*grpc.Server, *MockAgentData) { +func NewMockWindowsAgent(t *testing.T, ctx context.Context, publicDir string) *MockWindowsAgent { t.Helper() - var opts options - for _, f := range args { - f(&opts) - } - - server := grpc.NewServer() - service := &wslInstanceMockService{ - opts: opts, - } - - agentapi.RegisterWSLInstanceServer(server, service) - var cfg net.ListenConfig lis, err := cfg.Listen(ctx, "tcp4", "localhost:0") require.NoError(t, err, "Setup: could not listen to agent address") + clientCreds, serverCreds := agentTLSCreds(t, filepath.Join(publicDir, common.CertificatesDir)) + + m := MockWindowsAgent{ + Listener: lis, + Server: grpc.NewServer(grpc.Creds(serverCreds)), + Service: &mockWSLInstanceService{}, + ClientCredentials: clientCreds, + Started: make(chan struct{}), + Stopped: make(chan struct{}), + } + agentapi.RegisterWSLInstanceServer(m.Server, m.Service) + t.Cleanup(m.Stop) + + addrFile := filepath.Join(publicDir, common.ListeningPortFileName) + err = os.WriteFile(addrFile, []byte(lis.Addr().String()), 0600) + if err != nil { + close(m.Started) + close(m.Stopped) + require.Fail(t, "Setup: could not write listening port file: %v", err) + } + go func() { - log.Infof(ctx, "MockWindowsAgent: Windows-agent mock serving on %q", lis.Addr().String()) + log.Infof(ctx, "MockWindowsAgent: Windows-agent mock serving on %s", lis.Addr().String()) - t.Cleanup(server.Stop) + close(m.Started) + defer close(m.Stopped) - if err := server.Serve(lis); err != nil { + if err := m.Server.Serve(lis); err != nil { log.Infof(ctx, "MockWindowsAgent: Serve returned an error: %v", err) } - if err := os.Remove(addrFile); err != nil { + if err := os.RemoveAll(addrFile); err != nil { log.Infof(ctx, "MockWindowsAgent: Remove address file returned an error: %v", err) } }() - err = os.WriteFile(addrFile, []byte(lis.Addr().String()), 0600) - require.NoError(t, err, "Setup: could not write listening port file") + <-m.Started + + return &m +} + +// agentTLSCreds is a helper that creates a pair of TLS credentials for the agent and the WSL Pro service for testing. +func agentTLSCreds(t *testing.T, destDir string) (wslProService, agentCreds credentials.TransportCredentials) { + t.Helper() + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + require.NoError(t, err, "failed to generate serial number for the CA cert", err) + + require.NoError(t, os.MkdirAll(destDir, 0700), "failed to create certificates directory", err) + + rootCert, rootKey, err := certs.CreateRootCA("UP4W Test", serial, destDir) + require.NoError(t, err, "failed to create root CA", err) + + // Create and write the server and client certificates signed by the root certificate created above. + agentCert, err := certs.CreateTLSCertificateSignedBy("server", common.GRPCServerNameOverride, serial.Rsh(serial, 2), rootCert, rootKey, destDir) + require.NoError(t, err, "failed to create agent certificate", err) + wslProServiceCert, err := certs.CreateTLSCertificateSignedBy("client", "wsl-pro-service-test", serial.Lsh(serial, 3), rootCert, rootKey, destDir) + require.NoError(t, err, "failed to create WSL Pro service certificate", err) + + ca := x509.NewCertPool() + ca.AddCert(rootCert) + wslProService = credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS13, + ServerName: common.GRPCServerNameOverride, + Certificates: []tls.Certificate{*wslProServiceCert}, + RootCAs: ca, + }) + agentCreds = credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{*agentCert}, + ClientCAs: ca, + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: tls.VersionTLS13, + }) + + return wslProService, agentCreds +} + +// Stop releases all resources associated with the MockWindowsAgent. +func (m *MockWindowsAgent) Stop() { + <-m.Started + + if m.Server != nil { + m.Server.Stop() + } + + if m.Listener != nil { + m.Listener.Close() + } - return server, &service.data + <-m.Stopped } -type wslInstanceMockService struct { +type mockWSLInstanceService struct { agentapi.UnimplementedWSLInstanceServer - opts options - data MockAgentData + Connect channel[agentapi.DistroInfo, int, agentapi.WSLInstance_ConnectedServer] + ProAttachment channel[agentapi.MSG, agentapi.ProAttachCmd, agentapi.WSLInstance_ProAttachmentCommandsServer] + LandscapeConfig channel[agentapi.MSG, agentapi.LandscapeConfigCmd, agentapi.WSLInstance_LandscapeConfigCommandsServer] } -// MockAgentData contains some stats about the agent and the connections it made. -type MockAgentData struct { - // ConnectionCount is the number of times the WSL Pro Service has connected to the stream - ConnectionCount atomic.Int32 +func (s *mockWSLInstanceService) AllConnected() bool { + return s.Connect.connected() && s.ProAttachment.connected() && s.LandscapeConfig.connected() +} - // ConnectionCount is the number of times the Agent has connected to the WSLInstance service - BackConnectionCount atomic.Int32 +func (s *mockWSLInstanceService) AnyConnected() bool { + return s.Connect.connected() || s.ProAttachment.connected() || s.LandscapeConfig.connected() +} - // RecvCount is the number of completed Recv performed by the Agent - RecvCount atomic.Int32 +type receiver[Recv any] interface { + Recv() (*Recv, error) +} - // ReservedPort is the latest port reserved for the WSLProService - ReservedPort atomic.Uint32 +type sender[Send any] interface { + Send(*Send) error } -func (s *wslInstanceMockService) Connected(stream agentapi.WSLInstance_ConnectedServer) (err error) { - ctx := context.Background() +type channel[Recv any, Send any, Stream grpc.ServerStream] struct { + callCount int + recvHistory []Recv + stream *Stream + mu sync.Mutex +} - defer func(err *error) { - if *err != nil { - log.Warningf(ctx, "wslInstanceMockService: dropped connection: %v", *err) - return - } - log.Info(ctx, "wslInstanceMockService: dropped connection") - }(&err) +func (ch *channel[Recv, Send, Stream]) connected() bool { + ch.mu.Lock() + defer ch.mu.Unlock() - s.data.ConnectionCount.Add(1) + return ch.stream != nil +} - log.Infof(ctx, "wslInstanceMockService: Received incoming connection") +func (ch *channel[Recv, Send, Stream]) History() []*Recv { + ch.mu.Lock() + defer ch.mu.Unlock() - if s.opts.dropStreamBeforeFirstRecv { - log.Infof(ctx, "wslInstanceMockService: mock error: dropping stream before first Recv") - return nil + out := make([]*Recv, len(ch.recvHistory)) + for i, rcv := range ch.recvHistory { + out[i] = &rcv } - info, err := stream.Recv() - if err != nil { - return fmt.Errorf("new connection: did not receive info from WSL distro: %v", err) + return out +} + +func (ch *channel[Recv, Send, Stream]) NConnections() int { + ch.mu.Lock() + defer ch.mu.Unlock() + + return ch.callCount +} + +func (ch *channel[Recv, Send, Stream]) Send(msg *Send) error { + ch.mu.Lock() + tmp := ch.stream + ch.mu.Unlock() + + if tmp == nil { + return errors.New("MockWindowsAgent: not connected") + } + + snd, ok := any(*tmp).(sender[Send]) + if !ok { + panic("MockWindowsAgent: this channel cannot send") } - s.data.RecvCount.Add(1) - distro := info.GetWslName() - log.Infof(ctx, "wslInstanceMockService: Connection with %q: received info: %+v", distro, info) + return snd.Send(msg) +} + +func (ch *channel[Recv, Send, Stream]) recv() (*Recv, error) { + ch.mu.Lock() + tmp := ch.stream + ch.mu.Unlock() + + if tmp == nil { + return nil, errors.New("MockWindowsAgent: not connected") + } - if s.opts.dropStreamBeforeSendingPort { - log.Infof(ctx, "connection with %q: mock error: dropping stream before sending port", distro) - return nil + r, ok := any(*tmp).(receiver[Recv]) + if !ok { + panic("MockWindowsAgent: this channel cannot receive") } - // Get a port and send it - lis, err := net.Listen("tcp4", "localhost:") + rcv, err := r.Recv() if err != nil { - return fmt.Errorf("could not reserve a port for %q: %v", distro, err) + return nil, err } - var port int - // localhost:0 is a bad address to send, as 0 is not a real port, but rather instructs - // net.Listen to autoselect a new port; hence defeating the point of pre-autoselection. - if s.opts.sendBadPort { - log.Infof(ctx, "wslInstanceMockService: Connection with %q: Sending bad port %d", distro, port) - } else { - port, err = portFromAddress(lis.Addr().String()) - if err != nil { - return fmt.Errorf("could not parse address for %q: %v", distro, err) - } + ch.mu.Lock() + ch.recvHistory = append(ch.recvHistory, *rcv) + ch.mu.Unlock() - if err := lis.Close(); err != nil { - return fmt.Errorf("could not close port reserved for %q: %v", distro, err) - } + return rcv, nil +} - log.Infof(ctx, "wslInstanceMockService: Connection with %q: Reserved port %d", distro, port) - } +func (ch *channel[Recv, Send, Stream]) set(s Stream, helloMsg *Recv) { + ch.mu.Lock() + defer ch.mu.Unlock() + + ch.stream = &s + ch.callCount++ + ch.recvHistory = append(ch.recvHistory, *helloMsg) +} - s.data.ReservedPort.Store(uint32(port)) - if err := stream.Send(&agentapi.Port{Port: uint32(port)}); err != nil { - return fmt.Errorf("could not send port: %v", err) +func (ch *channel[Recv, Send, Stream]) reset() { + ch.mu.Lock() + defer ch.mu.Unlock() + + ch.stream = nil +} + +func (s *mockWSLInstanceService) Connected(stream agentapi.WSLInstance_ConnectedServer) (err error) { + defer decorate.LogOnError(&err) + + msg, err := stream.Recv() + if err != nil { + return err + } else if msg.GetWslName() == "" { + return errors.New("MockWindowsAgent: WSL name not provided") } - // Connect back - for i := 0; i < 5; i++ { - time.Sleep(5 * time.Second) - _, err = net.Dial("tcp4", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - err = fmt.Errorf("wslInstanceMockService: could not dial %q: %v", distro, err) - continue + s.Connect.set(stream, msg) + defer s.Connect.reset() + + log.Info(stream.Context(), "MockWindowsAgent: Connected ready") + + for { + _, err := s.Connect.recv() + if errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return fmt.Errorf("MockWindowsAgent: Connected stopped: %v", err) } - break } +} +func (s *mockWSLInstanceService) ProAttachmentCommands(stream agentapi.WSLInstance_ProAttachmentCommandsServer) (err error) { + defer decorate.LogOnError(&err) + + msg, err := stream.Recv() if err != nil { return err + } else if msg.GetWslName() == "" { + return errors.New("MockWindowsAgent: WSL name not provided") } - s.data.BackConnectionCount.Add(1) + s.ProAttachment.set(stream, msg) + defer s.ProAttachment.reset() - log.Infof(ctx, "wslInstanceMockService: Connection with %q: connected back via reserved port", distro) + log.Info(stream.Context(), "MockWindowsAgent: ProAttachmentCommands ready") - // Stay connected for { - _, err = stream.Recv() - if err != nil { - log.Infof(ctx, "wslInstanceMockService: Connection with %q ended: %v", distro, err) - break + _, err := s.ProAttachment.recv() + if errors.Is(err, io.EOF) { + log.Info(stream.Context(), "MockWindowsAgent: ProAttachmentCommands finished") + return nil + } else if err != nil { + return fmt.Errorf("MockWindowsAgent: ProAttachmentCommands stopped: %v", err) } - log.Infof(ctx, "wslInstanceMockService: Connection with %q: received info: %+v", distro, info) - s.data.RecvCount.Add(1) } - - return nil } -func portFromAddress(addr string) (int, error) { - _, p, err := net.SplitHostPort(addr) +func (s *mockWSLInstanceService) LandscapeConfigCommands(stream agentapi.WSLInstance_LandscapeConfigCommandsServer) (err error) { + defer decorate.LogOnError(&err) + + msg, err := stream.Recv() if err != nil { - return 0, fmt.Errorf("could not parse address %q", addr) + return err + } else if msg.GetWslName() == "" { + return errors.New("MockWindowsAgent: WSL name not provided") + } + + s.LandscapeConfig.set(stream, msg) + defer s.LandscapeConfig.reset() + + log.Info(stream.Context(), "MockWindowsAgent: LandscapeConfigCommands ready") + + for { + _, err := s.LandscapeConfig.recv() + if errors.Is(err, io.EOF) { + log.Info(stream.Context(), "MockWindowsAgent: LandscapeConfigCommands finished") + return nil + } else if err != nil { + return fmt.Errorf("MockWindowsAgent: LandscapeConfigCommands stopped: %v", err) + } } - return net.LookupPort("tcp4", fmt.Sprint(p)) } diff -Nru wsl-pro-service-0.1.2build1/internal/testutils/mock_executables.go wsl-pro-service-0.1.4/internal/testutils/mock_executables.go --- wsl-pro-service-0.1.2build1/internal/testutils/mock_executables.go 2024-02-01 16:26:13.000000000 +0000 +++ wsl-pro-service-0.1.4/internal/testutils/mock_executables.go 2024-04-03 11:34:21.000000000 +0000 @@ -3,10 +3,13 @@ package testutils import ( + "context" _ "embed" "errors" "fmt" "os" + "os/exec" + "os/user" "path/filepath" "strings" "syscall" @@ -34,6 +37,9 @@ // string when false WslDistroNameEnvEnabled bool + // LookupGroupError makes the LookupGroup function fail. + LandscapeGroupGID string + // extraEnv are extra environment variables that will be passed to mocked executables extraEnv []string } @@ -46,8 +52,8 @@ windowsUserProfileDir = `D:\Users\TestUser\` linuxUserProfileDir = "/mnt/d/Users/TestUser/" - // defaultAddrFile is the default path used in tests to store the address of the Windows Agent service. - defaultAddrFile = filepath.Join(linuxUserProfileDir, common.UserProfileDir, common.ListeningPortFileName) + // defaultPublicDir is the default path used in tests to store the address of the Windows Agent service. + defaultPublicDir = filepath.Join(linuxUserProfileDir, common.UserProfileDir) //go:embed filesystem_defaults/os-release defaultOsReleaseContents []byte @@ -109,24 +115,28 @@ // MockSystem sets up a few mocks: // - filesystem and mock executables for wslpath, pro. -func MockSystem(t *testing.T) (system.System, *SystemMock) { +func MockSystem(t *testing.T) (*system.System, *SystemMock) { t.Helper() + u, err := user.Current() + require.NoError(t, err, "Setup: could not get current user") + distroHostname := "TEST_DISTRO_HOSTNAME" mock := &SystemMock{ FsRoot: mockFilesystemRoot(t), WslDistroName: "TEST_DISTRO", DistroHostname: &distroHostname, WslDistroNameEnvEnabled: true, + LandscapeGroupGID: u.Gid, } return system.New(system.WithTestBackend(mock)), mock } -// DefaultAddrFile is the location where a mocked system will expect the addr file to be located, +// DefaultPublicDir is the location where a mocked system will expect the addr file to be located, // and its containing directory will be created in New(). -func (m *SystemMock) DefaultAddrFile() string { - return m.Path(defaultAddrFile) +func (m *SystemMock) DefaultPublicDir() string { + return m.Path(defaultPublicDir) } // SetControlArg adds control arguments to the mock executables. @@ -156,76 +166,94 @@ return "" } +// LookupGroup mocks the user.LookupGroup function. +func (m *SystemMock) LookupGroup(name string) (*user.Group, error) { + if name != "landscape" { + return nil, fmt.Errorf("mock does not support group %q", name) + } + + if m.LandscapeGroupGID == "" { + return nil, user.UnknownGroupError(name) + } + + return &user.Group{ + Gid: m.LandscapeGroupGID, + Name: name, + }, nil +} + // mockExec generates a command of the form `bash -ec