Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(Landscape): Apply new Landscape config changes to pre-existing distros #361

Merged
merged 8 commits into from
Oct 26, 2023
158 changes: 158 additions & 0 deletions windows-agent/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ package config

import (
"context"
"crypto/sha512"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"

"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/config/registry"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/contracts"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/distros/database"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/distros/task"
log "github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/grpc/logstreamer"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/tasks"
"github.com/ubuntu/decorate"
"golang.org/x/exp/slices"
)

const (
Expand Down Expand Up @@ -415,3 +421,155 @@ func (c *Config) FetchMicrosoftStoreSubscription(ctx context.Context) (err error

return nil
}

// UpdateRegistrySettings checks if any of the registry settings have changed since this function was last called.
// If so, new settings are pushed to the distros.
func (c *Config) UpdateRegistrySettings(ctx context.Context, cacheDir string, db *database.DistroDB) error {
EduardGomezEscandell marked this conversation as resolved.
Show resolved Hide resolved
type getTask = func(*Config, context.Context, string, *database.DistroDB) (task.Task, error)
didrocks marked this conversation as resolved.
Show resolved Hide resolved

// Collect tasks for updated settings
var errs error
var taskList []task.Task
for _, f := range []getTask{(*Config).getTaskOnNewSubscription, (*Config).getTaskOnNewLandscape} {
task, err := f(c, ctx, cacheDir, db)
if err != nil {
errs = errors.Join(errs, err)
continue
}
if task != nil {
taskList = append(taskList, task)
}
}

if errs != nil {
log.Warningf(ctx, "Could not obtain some updated registry settings: %v", errs)
}

// Apply tasks for updated settings
errs = nil
for _, d := range db.GetAll() {
errs = errors.Join(errs, d.SubmitDeferredTasks(taskList...))
}

if errs != nil {
return fmt.Errorf("could not submit new task to certain distros: %v", errs)
}

return nil
}

// getTaskOnNewSubscription checks if the subscription has changed since the last time it was called. If so, the new subscription
// is returned in the form of a task.
func (c *Config) getTaskOnNewSubscription(ctx context.Context, cacheDir string, db *database.DistroDB) (task.Task, error) {
proToken, _, err := c.Subscription(ctx)
if err != nil {
return nil, fmt.Errorf("could not retrieve current subscription: %v", err)
}

isNew, err := hasChanged(filepath.Join(cacheDir, "subscription.csum"), []byte(proToken))
if err != nil {
log.Warningf(ctx, "could not update checksum for Ubuntu Pro subscription: %v", err)
}

if !isNew {
return nil, nil
}

log.Debug(ctx, "New Ubuntu Pro subscription settings detected in registry")
return tasks.ProAttachment{Token: proToken}, nil
}

// getTaskOnNewLandscape checks if the Landscape settings has changed since the last time it was called. If so, the
// new Landscape settings are returned in the form of a task.
func (c *Config) getTaskOnNewLandscape(ctx context.Context, cacheDir string, db *database.DistroDB) (task.Task, error) {
landscapeConf, err := c.LandscapeClientConfig(ctx)
if err != nil {
return nil, fmt.Errorf("could not retrieve current landscape config: %v", err)
}

landscapeUID, err := c.LandscapeAgentUID(ctx)
if err != nil {
return nil, fmt.Errorf("could not retrieve current landscape UID: %v", err)
}

// We append them just so we can compute a combined checksum
didrocks marked this conversation as resolved.
Show resolved Hide resolved
serialized := fmt.Sprintf("%s%s", landscapeUID, landscapeConf)

isNew, err := hasChanged(filepath.Join(cacheDir, "landscape.csum"), []byte(serialized))
if err != nil {
log.Warningf(ctx, "could not update checksum for Landscape configuration: %v", err)
}

if !isNew {
return nil, nil
}

log.Debug(ctx, "New Landscape settings detected in registry")

// We must not register to landscape if we have no Landscape UID
if landscapeConf != "" && landscapeUID == "" {
log.Debug(ctx, "Ignoring new landscape settings: no Landscape agent UID")
return nil, nil
}

return tasks.LandscapeConfigure{Config: landscapeConf, HostagentUID: landscapeUID}, nil
}

// hasChanged detects if the current value is different from the last time it was used.
// The return value is usable even if error is returned.
func hasChanged(cachePath string, newValue []byte) (new bool, err error) {
var newCheckSum []byte
if len(newValue) != 0 {
tmp := sha512.Sum512(newValue)
newCheckSum = tmp[:]
}

defer decorateUpdateCache(&new, &err, cachePath, newCheckSum)

oldChecksum, err := os.ReadFile(cachePath)
if errors.Is(err, fs.ErrNotExist) {
// File not found: there was no value before
oldChecksum = nil
} else if err != nil {
return true, fmt.Errorf("could not read old value: %v", err)
}

if slices.Equal(oldChecksum, newCheckSum) {
return false, nil
}

return true, nil
}

// decorateUpdateCache acts depending on caller's return values (hence decorate).
// It stores the new checksum to the cachefile. Any errors are joined to *err.
func decorateUpdateCache(new *bool, err *error, cachePath string, newCheckSum []byte) {
didrocks marked this conversation as resolved.
Show resolved Hide resolved
writeCacheErr := func() error {
// If the value is empty, we remove the file.
// This preserves this function's idempotency.
if len(newCheckSum) == 0 {
err := os.Remove(cachePath)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("could not remove old checksum: %v", err)
}
return nil
}

// Value is unchanged: don't write to file
if !*new {
return nil
}

// Update to file
if err := os.WriteFile(cachePath, newCheckSum[:], 0600); err != nil {
return fmt.Errorf("could not write checksum to cache: %v", err)
}

return nil
}()

*err = errors.Join(*err, writeCacheErr)
}
92 changes: 92 additions & 0 deletions windows-agent/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ package config_test

import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"testing"

"github.com/canonical/ubuntu-pro-for-windows/common/wsltestutils"
config "github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/config"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/config/registry"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/distros/database"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/distros/distro"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/distros/task"
"github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/tasks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
wsl "github.com/ubuntu/gowsl"
wslmock "github.com/ubuntu/gowsl/mock"
)

// registryState represents how much data is in the registry.
Expand Down Expand Up @@ -432,6 +441,89 @@ func TestFetchMicrosoftStoreSubscription(t *testing.T) {
}
}

func TestUpdateRegistrySettings(t *testing.T) {
if wsl.MockAvailable() {
t.Parallel()
}

testCases := map[string]struct {
valueToChange string
registryState registryState
breakTaskfile bool

wantTasks []string
unwantedTasks []string
wantErr bool
}{
"Success changing Pro token": {valueToChange: "ProTokenOrg", registryState: keyExists, wantTasks: []string{"tasks.ProAttachment"}, unwantedTasks: []string{"tasks.LandscapeConfigure"}},
"Success changing Landscape without a UID": {valueToChange: "LandscapeClientConfig", registryState: keyExists, unwantedTasks: []string{"tasks.ProAttachment", "tasks.LandscapeConfigure"}},
"Success changing Landscape with a UID": {valueToChange: "LandscapeClientConfig", registryState: landscapeAgentUIDHasValue, wantTasks: []string{"tasks.LandscapeConfigure"}, unwantedTasks: []string{"tasks.ProAttachment"}},
"Success changing the Landscape UID": {valueToChange: "LandscapeAgentUID", registryState: keyExists, wantTasks: []string{"tasks.LandscapeConfigure"}, unwantedTasks: []string{"tasks.ProAttachment"}},

// Very implementation-detailed, but it's the only thing that actually triggers an error
"Error when the tasks cannot be submitted": {valueToChange: "ProTokenOrg", registryState: keyExists, breakTaskfile: true, wantErr: true},
}

for name, tc := range testCases {
tc := tc
t.Run(name, func(t *testing.T) {
ctx := context.Background()
if wsl.MockAvailable() {
t.Parallel()
ctx = wsl.WithMock(ctx, wslmock.New())
}

dir := t.TempDir()

distroName, _ := wsltestutils.RegisterDistro(t, ctx, false)
taskFilePath := filepath.Join(dir, distroName+".tasks")

db, err := database.New(ctx, dir, nil)
require.NoError(t, err, "Setup: could create empty database")

_, err = db.GetDistroAndUpdateProperties(ctx, distroName, distro.Properties{})
require.NoError(t, err, "Setup: could not add dummy distro to database")

r := setUpMockRegistry(0, tc.registryState, false)
require.NoError(t, err, "Setup: could not create empty database")

c := config.New(ctx, config.WithRegistry(r))

// Update value in registry
r.UbuntuProData[tc.valueToChange] = "NEW_VALUE!"

if tc.breakTaskfile {
err := os.MkdirAll(taskFilePath, 0600)
require.NoError(t, err, "could not create directory to interfere with task file")
}

err = c.UpdateRegistrySettings(ctx, dir, db)
if tc.wantErr {
require.Error(t, err, "UpdateRegistrySettings should return an error")
return
}
require.NoError(t, err, "UpdateRegistrySettings should return no error")

out, err := readFileOrEmpty(taskFilePath)
require.NoError(t, err, "Could not read distro taskfile")
for _, task := range tc.wantTasks {
assert.Containsf(t, out, task, "Distro should have received a %s task", task)
}
for _, task := range tc.unwantedTasks {
assert.NotContainsf(t, out, task, "Distro should have received a %s task", task)
}
didrocks marked this conversation as resolved.
Show resolved Hide resolved
})
}
}

func readFileOrEmpty(path string) (string, error) {
out, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return string(out), err
}

// is defines equality between flags. It is convenience function to check if a registryState matches a certain state.
func (state registryState) is(flag registryState) bool {
return state&flag == flag
Expand Down
Loading
Loading