diff --git a/docs/03-restart.md b/docs/03-restart.md index dc6752cb2..e5a423631 100644 --- a/docs/03-restart.md +++ b/docs/03-restart.md @@ -34,4 +34,4 @@ If you want to restart UP4W with factory settings, do: ``` 1. Remove folder `$env:LocalAppData\UbuntuPro`. 2. Remove registry key `HKEY_CURRENT_USER\Software\Canonical\UbuntuPro`. -3. You're done. Next time you start the GUI it'll be like a fresh install. \ No newline at end of file +3. You're done. Next time you start the GUI it'll be like a fresh install. diff --git a/docs/04-enable-pro.md b/docs/04-enable-pro.md index aef66a0c8..a90380bc1 100644 --- a/docs/04-enable-pro.md +++ b/docs/04-enable-pro.md @@ -11,6 +11,6 @@ ``` ## Organisational pro enablement -1. Find registry key `HKEY_CURRENT_USER\Software\Canonical\UbuntuPro`, field `ProTokenOrg`. +1. Find registry key `HKEY_CURRENT_USER\Software\Canonical\UbuntuPro`, field `UbuntuProToken`. 2. Write your Ubuntu Pro Token into the registry key -3. The changes will take effect next time you start Ubuntu Pro For Windows. All new distros will automatically become pro-enabled. If you want them to be applied now, follow the steps on how to restart Ubuntu Pro For Windows. Otherwise, you're done. \ No newline at end of file +3. The changes will take effect next time you start Ubuntu Pro For Windows. All new distros will automatically become pro-enabled. If you want them to be applied now, follow the steps on how to restart Ubuntu Pro For Windows. Otherwise, you're done. diff --git a/docs/05-attach-landscape.md b/docs/05-attach-landscape.md index 5bd1b82f0..6e0881743 100644 --- a/docs/05-attach-landscape.md +++ b/docs/05-attach-landscape.md @@ -1,6 +1,6 @@ # How to auto-register WSL distros to Landscape with UP4W You can use a private Landscape instance (different from [landscape.canonical.com](https://landscape.canonical.com)). It must be over HTTP, as using certificates is not yet supported. To do so, follow these steps: -1. Find registry key `HKEY_CURRENT_USER\Software\Canonical\UbuntuPro`, field `LandscapeClientConfig`. +1. Find registry key `HKEY_CURRENT_USER\Software\Canonical\UbuntuPro`, field `LandscapeConfig`. 2. Copy the contents of the Landscape configuration file into the registry key: ```ini [host] diff --git a/windows-agent/internal/config/config.go b/windows-agent/internal/config/config.go index d4b948ffe..902297700 100644 --- a/windows-agent/internal/config/config.go +++ b/windows-agent/internal/config/config.go @@ -5,10 +5,9 @@ package config import ( "context" "crypto/sha512" + "encoding/base64" "errors" "fmt" - "io/fs" - "os" "path/filepath" "sync" @@ -19,22 +18,9 @@ import ( 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 ( - registryPath = `Software\Canonical\UbuntuPro` - - fieldLandscapeClientConfig = "LandscapeClientConfig" - fieldLandscapeAgentUID = "LandscapeAgentUID" -) - -// fieldsProToken contains the fields in the registry where each source will store its token. -var fieldsProToken = map[SubscriptionSource]string{ - SubscriptionOrganization: "ProTokenOrg", - SubscriptionUser: "ProTokenUser", - SubscriptionMicrosoftStore: "ProTokenStore", -} +const registryPath = `Software\Canonical\UbuntuPro` // Registry abstracts away access to the windows registry. type Registry interface { @@ -49,44 +35,18 @@ type Registry interface { // Config manages configuration parameters. It is a wrapper around a dictionary // that reads and updates the config file. type Config struct { - proTokens map[SubscriptionSource]string - data configData + // data + subscription subscription + landscape landscapeConf - registry Registry + // disk backing + registry Registry + cachePath string + // Sync mu *sync.Mutex } -// configData is a bag of data unrelated to the subscription status. -type configData struct { - landscapeClientConfig string - landscapeAgentUID string -} - -// SubscriptionSource indicates the method the subscription was acquired. -type SubscriptionSource int - -// Subscription types. Sorted in ascending order of precedence. -const ( - // SubscriptionNone -> no subscription. - SubscriptionNone SubscriptionSource = iota - - // SubscriptionOrganization -> the subscription was obtained by introducing a pro token - // via the registry by the sys admin. - SubscriptionOrganization - - // SubscriptionUser -> the subscription was obtained by introducing a pro token - // via the registry or the GUI. - SubscriptionUser - - // SubscriptionMicrosoftStore -> the subscription was acquired via the Microsoft Store. - SubscriptionMicrosoftStore - - // subscriptionMaxPriority is a sentinel value to make looping simpler. - // It must always be the last value in the enum. - subscriptionMaxPriority -) - type options struct { registry Registry } @@ -102,7 +62,7 @@ func WithRegistry(r Registry) Option { } // New creates and initializes a new Config object. -func New(ctx context.Context, args ...Option) (m *Config) { +func New(ctx context.Context, cachePath string, args ...Option) (m *Config) { var opts options for _, f := range args { @@ -115,43 +75,26 @@ func New(ctx context.Context, args ...Option) (m *Config) { m = &Config{ registry: opts.registry, + cachePath: filepath.Join(cachePath, "config"), mu: &sync.Mutex{}, - proTokens: make(map[SubscriptionSource]string), } return m } // Subscription returns the ProToken and the method it was acquired with (if any). -func (c *Config) Subscription(ctx context.Context) (token string, source SubscriptionSource, err error) { +func (c *Config) Subscription(ctx context.Context) (token string, source Source, err error) { c.mu.Lock() defer c.mu.Unlock() - if err := c.load(ctx); err != nil { - return "", SubscriptionNone, fmt.Errorf("could not load: %v", err) + if err := c.load(); err != nil { + return "", SourceNone, fmt.Errorf("could not load: %v", err) } - token, source = c.subscription() + token, source = c.subscription.resolve() return token, source, nil } -// subscription is the unsafe version of Subscription. It returns the ProToken and the method it was acquired with (if any). -func (c *Config) subscription() (token string, source SubscriptionSource) { - for src := subscriptionMaxPriority - 1; src > SubscriptionNone; src-- { - token, ok := c.proTokens[src] - if !ok { - continue - } - if token == "" { - continue - } - - return token, src - } - - return "", SubscriptionNone -} - // IsReadOnly returns whether the registry can be written to. func (c *Config) IsReadOnly() (b bool, err error) { // CreateKey is equivalent to OpenKey if the key already existed @@ -175,201 +118,90 @@ func (c *Config) ProvisioningTasks(ctx context.Context, distroName string) ([]ta c.mu.Lock() defer c.mu.Unlock() - if err := c.load(ctx); err != nil { + if err := c.load(); err != nil { return nil, fmt.Errorf("could not load: %v", err) } // Ubuntu Pro attachment - proToken, _ := c.subscription() + proToken, _ := c.subscription.resolve() taskList = append(taskList, tasks.ProAttachment{Token: proToken}) - if c.data.landscapeClientConfig == "" { + if lp, _ := c.landscape.resolve(); lp == "" { // Landscape unregistration: always taskList = append(taskList, tasks.LandscapeConfigure{}) - } else if c.data.landscapeAgentUID != "" { + } else if c.landscape.UID != "" { // Landcape registration: only when we have a UID assigned taskList = append(taskList, tasks.LandscapeConfigure{ - Config: c.data.landscapeClientConfig, - HostagentUID: c.data.landscapeAgentUID, + Config: lp, + HostagentUID: c.landscape.UID, }) } return taskList, nil } -// SetSubscription overwrites the value of the pro token and the method with which it has been acquired. -func (c *Config) SetSubscription(ctx context.Context, proToken string, source SubscriptionSource) error { +// LandscapeClientConfig returns the value of the landscape server URL and +// the method it was acquired with (if any). +func (c *Config) LandscapeClientConfig(ctx context.Context) (string, Source, error) { c.mu.Lock() defer c.mu.Unlock() - // Load before dumping to avoid overriding recent changes to registry - if err := c.load(ctx); err != nil { - return err - } - - old := c.proTokens[source] - c.proTokens[source] = proToken - - if err := c.dump(); err != nil { - log.Errorf(ctx, "Could not update subscription in registry, token will be ignored: %v", err) - c.proTokens[source] = old - return err + if err := c.load(); err != nil { + return "", SourceNone, fmt.Errorf("could not load: %v", err) } - return nil + conf, src := c.landscape.resolve() + return conf, src, nil } -// LandscapeClientConfig returns the value of the landscape server URL. -func (c *Config) LandscapeClientConfig(ctx context.Context) (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if err := c.load(ctx); err != nil { - return "", fmt.Errorf("could not load: %v", err) - } - - return c.data.landscapeClientConfig, nil +// SetUserSubscription overwrites the value of the user-provided Ubuntu Pro token. +func (c *Config) SetUserSubscription(ctx context.Context, proToken string) error { + return c.set(ctx, &c.subscription.User, proToken) } -// LandscapeAgentUID returns the UID assigned to this agent by the Landscape server. -// An empty string is returned if no UID has been assigned. -func (c *Config) LandscapeAgentUID(ctx context.Context) (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if err := c.load(ctx); err != nil { - return "", fmt.Errorf("could not load: %v", err) - } - - return c.data.landscapeAgentUID, nil +// setStoreSubscription overwrites the value of the store-provided Ubuntu Pro token. +func (c *Config) setStoreSubscription(ctx context.Context, proToken string) error { + return c.set(ctx, &c.subscription.Store, proToken) } // SetLandscapeAgentUID overrides the Landscape agent UID. func (c *Config) SetLandscapeAgentUID(ctx context.Context, uid string) error { + return c.set(ctx, &c.landscape.UID, uid) +} + +// set is a generic method to safely modify the config. +func (c *Config) set(ctx context.Context, field *string, value string) error { c.mu.Lock() defer c.mu.Unlock() // Load before dumping to avoid overriding recent changes to registry - if err := c.load(ctx); err != nil { + if err := c.load(); err != nil { return err } - old := c.data.landscapeAgentUID - c.data.landscapeAgentUID = uid + old := *field + *field = value if err := c.dump(); err != nil { - log.Errorf(ctx, "Could not update landscape agent UID in registry, UID will be ignored: %v", err) - c.data.landscapeAgentUID = old - return err - } - - return nil -} - -func (c *Config) load(ctx context.Context) (err error) { - defer decorate.OnError(&err, "could not load data for Config") - - // Read registry - proTokens, data, err := c.loadRegistry(ctx) - if err != nil { + log.Errorf(ctx, "Could not update settings: %v", err) + *field = old return err } - // Commit to loaded data - c.proTokens = proTokens - c.data = data - return nil } -func (c *Config) loadRegistry(ctx context.Context) (proTokens map[SubscriptionSource]string, data configData, err error) { - defer decorate.OnError(&err, "could not load from registry") - - proTokens = make(map[SubscriptionSource]string) - - k, err := c.registry.HKCUOpenKey(registryPath, registry.READ) - if errors.Is(err, registry.ErrKeyNotExist) { - log.Debug(ctx, "Registry key does not exist, using default values") - return proTokens, data, nil - } - if err != nil { - return proTokens, data, err - } - defer c.registry.CloseKey(k) - - for source, field := range fieldsProToken { - proToken, e := c.readValue(ctx, k, field) - if e != nil { - err = errors.Join(err, fmt.Errorf("could not read %q: %v", field, e)) - continue - } - - if proToken == "" { - continue - } - - proTokens[source] = proToken - } - - if err != nil { - return nil, data, err - } - - data.landscapeClientConfig, err = c.readValue(ctx, k, fieldLandscapeClientConfig) - if err != nil { - return proTokens, data, err - } - - data.landscapeAgentUID, err = c.readValue(ctx, k, fieldLandscapeAgentUID) - if err != nil { - return proTokens, data, err - } - - return proTokens, data, nil -} - -func (c *Config) readValue(ctx context.Context, key uintptr, field string) (string, error) { - value, err := c.registry.ReadValue(key, field) - if errors.Is(err, registry.ErrFieldNotExist) { - log.Debugf(ctx, "Registry value %q does not exist, defaulting to empty", field) - return "", nil - } - if err != nil { - return "", err - } - return value, nil -} - -func (c *Config) dump() (err error) { - defer decorate.OnError(&err, "could not store Config data") - - // CreateKey is equivalent to OpenKey if the key already existed - k, err := c.registry.HKCUCreateKey(registryPath, registry.WRITE) - if err != nil { - return fmt.Errorf("could not open or create registry key: %w", err) - } - defer c.registry.CloseKey(k) - - for source, field := range fieldsProToken { - err := c.registry.WriteValue(k, field, c.proTokens[source]) - if err != nil { - return fmt.Errorf("could not write into registry key: %w", err) - } - } - - if err := c.registry.WriteMultilineValue(k, fieldLandscapeClientConfig, c.data.landscapeClientConfig); err != nil { - return fmt.Errorf("could not write into registry key: %v", err) - } - - if err := c.registry.WriteValue(k, fieldLandscapeAgentUID, c.data.landscapeAgentUID); err != nil { - return fmt.Errorf("could not write into registry key: %v", err) - } +// LandscapeAgentUID returns the UID assigned to this agent by the Landscape server. +// An empty string is returned if no UID has been assigned. +func (c *Config) LandscapeAgentUID(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() - if err := c.registry.WriteValue(k, fieldLandscapeAgentUID, c.data.landscapeAgentUID); err != nil { - return fmt.Errorf("could not write into registry key: %v", err) + if err := c.load(); err != nil { + return "", fmt.Errorf("could not load: %v", err) } - return nil + return c.landscape.UID, nil } // FetchMicrosoftStoreSubscription contacts Ubuntu Pro's contract server and the Microsoft Store @@ -392,7 +224,7 @@ func (c *Config) FetchMicrosoftStoreSubscription(ctx context.Context) (err error return fmt.Errorf("could not get ProToken from Microsoft Store: %v", err) } - if err := c.SetSubscription(ctx, proToken, SubscriptionMicrosoftStore); err != nil { + if err := c.setStoreSubscription(ctx, proToken); err != nil { return err } @@ -401,14 +233,42 @@ func (c *Config) FetchMicrosoftStoreSubscription(ctx context.Context) (err error // 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 { - type getTask = func(*Config, context.Context, string, *database.DistroDB) (task.Task, error) +func (c *Config) UpdateRegistrySettings(ctx context.Context, db *database.DistroDB) error { + taskList, err := c.collectRegistrySettingsTasks(ctx, db) + if err != nil { + return err + } + + // Apply tasks for updated settings + for _, d := range db.GetAll() { + err = errors.Join(err, d.SubmitDeferredTasks(taskList...)) + } + + if err != nil { + return fmt.Errorf("could not submit new token to certain distros: %v", err) + } + + return nil +} + +// collectRegistrySettingsTasks looks at the registry settings to see if any of them have changed since this +// function was last called. It returns a list of tasks to run triggered by these changes. +func (c *Config) collectRegistrySettingsTasks(ctx context.Context, db *database.DistroDB) ([]task.Task, error) { + type getTask = func(*Config, context.Context, *database.DistroDB) (task.Task, error) + + c.mu.Lock() + defer c.mu.Unlock() + + // Load up-to-date state + if err := c.load(); err != nil { + return nil, err + } // 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) + task, err := f(c, ctx, db) if err != nil { errs = errors.Join(errs, err) continue @@ -422,131 +282,60 @@ func (c *Config) UpdateRegistrySettings(ctx context.Context, cacheDir string, db 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) + // Dump updated checksums + if err := c.dump(); err != nil { + return nil, fmt.Errorf("could not store updated registry settings: %v", err) } - return nil + return taskList, 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 { +func (c *Config) getTaskOnNewSubscription(ctx context.Context, db *database.DistroDB) (task.Task, error) { + if !hasChanged(c.subscription.Organization, &c.subscription.Checksum) { return nil, nil } + log.Debug(ctx, "New organization-provided Ubuntu Pro subscription settings detected in registry") - log.Debug(ctx, "New Ubuntu Pro subscription settings detected in registry") + proToken, _ := c.subscription.resolve() 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) - } - +func (c *Config) getTaskOnNewLandscape(ctx context.Context, db *database.DistroDB) (task.Task, error) { // We append them just so we can compute a combined checksum - 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 { + serialized := fmt.Sprintf("%s%s", c.landscape.OrgConfig, c.landscape.UID) + if !hasChanged(serialized, &c.landscape.Checksum) { return nil, nil } log.Debug(ctx, "New Landscape settings detected in registry") + lconf, _ := c.landscape.resolve() // We must not register to landscape if we have no Landscape UID - if landscapeConf != "" && landscapeUID == "" { + if lconf != "" && c.landscape.UID == "" { log.Debug(ctx, "Ignoring new landscape settings: no Landscape agent UID") return nil, nil } - return tasks.LandscapeConfigure{Config: landscapeConf, HostagentUID: landscapeUID}, nil + return tasks.LandscapeConfigure{Config: lconf, HostagentUID: c.landscape.UID}, 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 the value has changed, the checksum will be updated. +func hasChanged(newValue string, checksum *string) bool { + var newCheckSum string 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) + raw := sha512.Sum512([]byte(newValue)) + newCheckSum = base64.StdEncoding.EncodeToString(raw[:]) } - if slices.Equal(oldChecksum, newCheckSum) { - return false, nil + if *checksum == newCheckSum { + return false } - 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) { - 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) + *checksum = newCheckSum + return true } diff --git a/windows-agent/internal/config/config_marshal.go b/windows-agent/internal/config/config_marshal.go new file mode 100644 index 000000000..4fb857db1 --- /dev/null +++ b/windows-agent/internal/config/config_marshal.go @@ -0,0 +1,120 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/canonical/ubuntu-pro-for-windows/windows-agent/internal/config/registry" + "github.com/ubuntu/decorate" + "gopkg.in/yaml.v3" +) + +type marshalHelper struct { + Landscape landscapeConf + Subscription subscription +} + +func (c *Config) load() (err error) { + defer decorate.OnError(&err, "could not load data for Config") + + var h marshalHelper + + if err := h.loadFile(c.cachePath); err != nil { + return fmt.Errorf("could not load config from the cache file: %v", err) + } + + if err := h.loadRegistry(c.registry); err != nil { + return fmt.Errorf("could not load config from the registry: %v", err) + } + + c.landscape = h.Landscape + c.subscription = h.Subscription + + return nil +} + +func (h *marshalHelper) loadFile(cachePath string) (err error) { + out, err := os.ReadFile(cachePath) + if errors.Is(err, fs.ErrNotExist) { + out = []byte{} + } else if err != nil { + return fmt.Errorf("could not read cache file: %v", err) + } + + if err := yaml.Unmarshal(out, h); err != nil { + return fmt.Errorf("could not umarshal cache file: %v", err) + } + + return nil +} + +func (h *marshalHelper) loadRegistry(reg Registry) (err error) { + k, err := reg.HKCUOpenKey(registryPath, registry.READ) + if errors.Is(err, registry.ErrKeyNotExist) { + // Default values + h.Subscription.Organization = "" + h.Landscape.OrgConfig = "" + return nil + } + if err != nil { + return err + } + defer reg.CloseKey(k) + + proToken, err := readFromRegistry(reg, k, "UbuntuProToken") + if err != nil { + return err + } + + config, err := readFromRegistry(reg, k, "LandscapeConfig") + if err != nil { + return err + } + + h.Subscription.Organization = proToken + h.Landscape.OrgConfig = config + + return nil +} + +func readFromRegistry(r Registry, key uintptr, field string) (string, error) { + value, err := r.ReadValue(key, field) + if errors.Is(err, registry.ErrFieldNotExist) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("could not read %q from registry", field) + } + + return value, nil +} + +func (c Config) dump() (err error) { + defer decorate.OnError(&err, "could not store Config data") + + h := marshalHelper{ + Landscape: c.landscape, + Subscription: c.subscription, + } + + if err := h.dumpFile(c.cachePath); err != nil { + return err + } + + return nil +} + +func (h marshalHelper) dumpFile(cachePath string) error { + out, err := yaml.Marshal(h) + if err != nil { + return fmt.Errorf("could not marshal config: %v", err) + } + + if err := os.WriteFile(cachePath, out, 0600); err != nil { + return fmt.Errorf("could not write config cache file: %v", err) + } + + return nil +} diff --git a/windows-agent/internal/config/config_source.go b/windows-agent/internal/config/config_source.go new file mode 100644 index 000000000..17225474f --- /dev/null +++ b/windows-agent/internal/config/config_source.go @@ -0,0 +1,62 @@ +package config + +// Source indicates the method a configuration parameter was acquired. +type Source int + +// config Source types. +const ( + // SourceNone -> no data. + SourceNone Source = iota + + // SourceRegistry -> the data was obtained from the registry. + SourceRegistry + + // SourceUser -> the data was introduced by the user. + SourceUser + + // SourceMicrosoftStore -> the data was acquired via the Microsoft Store. + SourceMicrosoftStore +) + +type subscription struct { + User string + Store string + Organization string `yaml:"-"` + Checksum string +} + +func (s subscription) resolve() (string, Source) { + if s.Store != "" { + return s.Store, SourceMicrosoftStore + } + + if s.User != "" { + return s.User, SourceUser + } + + if s.Organization != "" { + return s.Organization, SourceRegistry + } + + return "", SourceNone +} + +type landscapeConf struct { + UserConfig string `yaml:"config"` + OrgConfig string `yaml:"-"` + + UID string + Checksum string +} + +func (p landscapeConf) resolve() (string, Source) { + if p.UserConfig != "" { + return p.UserConfig, SourceUser + } + + if p.OrgConfig != "" { + return p.OrgConfig, SourceRegistry + } + + return "", SourceNone +} diff --git a/windows-agent/internal/config/config_test.go b/windows-agent/internal/config/config_test.go index f28f2a7eb..b093693b3 100644 --- a/windows-agent/internal/config/config_test.go +++ b/windows-agent/internal/config/config_test.go @@ -19,26 +19,34 @@ import ( "github.com/stretchr/testify/require" wsl "github.com/ubuntu/gowsl" wslmock "github.com/ubuntu/gowsl/mock" + "gopkg.in/yaml.v3" ) -// registryState represents how much data is in the registry. -type registryState uint64 +// settingsState represents how much data is in the registry. +type settingsState uint64 const ( - untouched registryState = 0x00 // Nothing UbuntuPro-related exists, as though the program had never ran before - keyExists registryState = 0x01 // Key exists but is empty - - orgTokenExists = keyExists | 1<<(iota+2) // Key exists, organization token field exists - userTokenExists // Key exists, user token field exists - storeTokenExists // Key exists, microsoft store token field exists - landscapeClientConfigExists // Key exists, landscape client config field exists - landscapeAgentUIDExists // Key exists, landscape agent UID field exists - - orgTokenHasValue = orgTokenExists | 1<<16 // Key exists, organization token field exists and is not empty - userTokenHasValue = userTokenExists | 1<<17 // Key exists, user token field exists and is not empty - storeTokenHasValue = storeTokenExists | 1<<18 // Key exists, microsoft store token field exists and is not empty - landscapeClientConfigHasValue = landscapeClientConfigExists | 1<<19 // Key exists, landscape client config field exists and is not empty - landscapeAgentUIDHasValue = landscapeAgentUIDExists | 1<<20 // Key exists, landscape agent UID field exists and is not empty + untouched settingsState = 0 // Nothing UbuntuPro-related exists, as though the program had never ran before + keyExists settingsState = 1 // Key exists but is empty + fileExists settingsState = 2 // File exists but is empty + + // Registry settings. + orgTokenExists = keyExists | 1<<3 // Key exists, organization token exists + orgLandscapeConfigExists = keyExists | 1<<4 // Key exists, organization landscape config exists + + orgTokenHasValue = orgTokenExists | 1<<5 // Key exists, organization token exists, and is not empty + orgLandscapeConfigHasValue = orgTokenExists | 1<<6 // Key exists, organization landscape config , and is not empty + + // File settings. + userTokenExists = fileExists | 1<<(7+iota) // File exists, user token exists + storeTokenExists // File exists, microsoft store token exists + userLandscapeConfigExists // File exists, landscape client config exists + landscapeUIDExists // File exists, landscape agent UID exists + + userTokenHasValue = userTokenExists | 1<<20 // File exists, user token exists, and is not empty + storeTokenHasValue = storeTokenExists | 1<<21 // File exists, microsoft store token exists, and is not empty + userLandscapeConfigHasValue = userLandscapeConfigExists | 1<<22 // File exists, landscape client config exists, and is not empty + landscapeUIDHasValue = landscapeUIDExists | 1<<23 // File exists, landscape agent UID exists, and is not empty ) func TestSubscription(t *testing.T) { @@ -46,27 +54,29 @@ func TestSubscription(t *testing.T) { testCases := map[string]struct { mockErrors uint32 - registryState registryState + breakFile bool + settingsState settingsState wantToken string - wantSource config.SubscriptionSource + wantSource config.Source wantError bool }{ - "Success": {registryState: userTokenHasValue, wantToken: "user_token", wantSource: config.SubscriptionUser}, - "Success when the key does not exist": {registryState: untouched}, - "Success when the key exists but is empty": {registryState: keyExists}, - "Success when the key exists but contains empty fields": {registryState: orgTokenExists | userTokenExists | storeTokenExists}, - - "Success when there is an organization token": {registryState: orgTokenHasValue, wantToken: "org_token", wantSource: config.SubscriptionOrganization}, - "Success when there is a user token": {registryState: userTokenHasValue, wantToken: "user_token", wantSource: config.SubscriptionUser}, - "Success when there is a store token": {registryState: storeTokenHasValue, wantToken: "store_token", wantSource: config.SubscriptionMicrosoftStore}, - - "Success when there are organization and user tokens": {registryState: orgTokenHasValue | userTokenHasValue, wantToken: "user_token", wantSource: config.SubscriptionUser}, - "Success when there are organization and store tokens": {registryState: orgTokenHasValue | storeTokenHasValue, wantToken: "store_token", wantSource: config.SubscriptionMicrosoftStore}, - "Success when there are organization and user tokens, and an empty store token": {registryState: orgTokenHasValue | userTokenHasValue | storeTokenExists, wantToken: "user_token", wantSource: config.SubscriptionUser}, - - "Error when the registry key cannot be opened": {registryState: userTokenHasValue, mockErrors: registry.MockErrOnOpenKey, wantError: true}, - "Error when the registry key cannot be read from": {registryState: userTokenHasValue, mockErrors: registry.MockErrReadValue, wantError: true}, + "Success": {settingsState: userTokenHasValue, wantToken: "user_token", wantSource: config.SourceUser}, + "Success when neither registry key nor conf file exist": {settingsState: untouched}, + "Success when the key exists but is empty": {settingsState: keyExists}, + "Success when the key exists but contains empty fields": {settingsState: orgTokenExists}, + + "Success when there is an organization token": {settingsState: orgTokenHasValue, wantToken: "org_token", wantSource: config.SourceRegistry}, + "Success when there is a user token": {settingsState: userTokenHasValue, wantToken: "user_token", wantSource: config.SourceUser}, + "Success when there is a store token": {settingsState: storeTokenHasValue, wantToken: "store_token", wantSource: config.SourceMicrosoftStore}, + + "Success when there are organization and user tokens": {settingsState: orgTokenHasValue | userTokenHasValue, wantToken: "user_token", wantSource: config.SourceUser}, + "Success when there are organization and store tokens": {settingsState: orgTokenHasValue | storeTokenHasValue, wantToken: "store_token", wantSource: config.SourceMicrosoftStore}, + "Success when there are organization and user tokens, and an empty store token": {settingsState: orgTokenHasValue | userTokenHasValue | storeTokenExists, wantToken: "user_token", wantSource: config.SourceUser}, + + "Error when the registry key cannot be opened": {settingsState: orgTokenHasValue, mockErrors: registry.MockErrOnOpenKey, wantError: true}, + "Error when the registry key cannot be read from": {settingsState: orgTokenHasValue, mockErrors: registry.MockErrReadValue, wantError: true}, + "Error when the file cannot be read from": {settingsState: untouched, breakFile: true, wantError: true}, } for name, tc := range testCases { @@ -75,8 +85,8 @@ func TestSubscription(t *testing.T) { t.Parallel() ctx := context.Background() - r := setUpMockRegistry(tc.mockErrors, tc.registryState, false) - conf := config.New(ctx, config.WithRegistry(r)) + r, dir := setUpMockSettings(t, tc.mockErrors, tc.settingsState, false, tc.breakFile) + conf := config.New(ctx, dir, config.WithRegistry(r)) token, source, err := conf.Subscription(ctx) if tc.wantError { @@ -93,49 +103,74 @@ func TestSubscription(t *testing.T) { } } -func TestLandscapeClientConfig(t *testing.T) { +func TestLandscapeConfig(t *testing.T) { t.Parallel() - testConfigGetter(t, testConfigGetterSettings{ - getter: (*config.Config).LandscapeClientConfig, - getterName: "LandscapeClientConfig", - registryHasValue: landscapeClientConfigHasValue, - want: "[client]\nuser=JohnDoe", - }) -} + testCases := map[string]struct { + mockErrors uint32 + breakFile bool + settingsState settingsState -func TestLandscapeAgentUID(t *testing.T) { - t.Parallel() + wantLandscapeConfig string + wantSource config.Source + wantError bool + }{ + "Success": {settingsState: userLandscapeConfigHasValue, wantLandscapeConfig: "[client]\nuser=JohnDoe", wantSource: config.SourceUser}, - testConfigGetter(t, testConfigGetterSettings{ - getter: (*config.Config).LandscapeAgentUID, - getterName: "LandscapeAgentUID", - registryHasValue: landscapeAgentUIDHasValue, - want: "landscapeUID1234", - }) -} + "Success when neither registry key nor conf file exist": {settingsState: untouched}, + "Success when the registry key exists but is empty": {settingsState: keyExists}, + "Success when the registry key exists but contains empty fields": {settingsState: orgLandscapeConfigExists}, + + "Success when there is an organization conf": {settingsState: orgLandscapeConfigHasValue, wantLandscapeConfig: "[client]\nuser=BigOrg", wantSource: config.SourceRegistry}, + "Success when there is a user conf": {settingsState: userLandscapeConfigHasValue, wantLandscapeConfig: "[client]\nuser=JohnDoe", wantSource: config.SourceUser}, + + "Success when there are organization and user confs": {settingsState: orgLandscapeConfigHasValue | userLandscapeConfigHasValue, wantLandscapeConfig: "[client]\nuser=JohnDoe", wantSource: config.SourceUser}, -type testConfigGetterSettings struct { - getter func(*config.Config, context.Context) (string, error) - getterName string - registryHasValue registryState - want string + "Error when the registry key cannot be opened": {settingsState: orgTokenHasValue, mockErrors: registry.MockErrOnOpenKey, wantError: true}, + "Error when the registry key cannot be read from": {settingsState: orgTokenHasValue, mockErrors: registry.MockErrReadValue, wantError: true}, + "Error when the file cannot be read from": {settingsState: untouched, breakFile: true, wantError: true}, + } + + for name, tc := range testCases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + r, dir := setUpMockSettings(t, tc.mockErrors, tc.settingsState, false, tc.breakFile) + conf := config.New(ctx, dir, config.WithRegistry(r)) + + token, source, err := conf.LandscapeClientConfig(ctx) + if tc.wantError { + require.Error(t, err, "ProToken should return an error") + return + } + require.NoError(t, err, "ProToken should return no error") + + // Test values + require.Equal(t, tc.wantLandscapeConfig, token, "Unexpected token value") + require.Equal(t, tc.wantSource, source, "Unexpected token source") + assert.Zero(t, r.OpenKeyCount.Load(), "Leaking keys after ProToken") + }) + } } -//nolint:thelper // This is the test itself, not a helper. Besides, a t.Helper() here would not affect the subtests. -func testConfigGetter(t *testing.T, s testConfigGetterSettings) { +func TestLandscapeAgentUID(t *testing.T) { + t.Parallel() + testCases := map[string]struct { - mockErrors uint32 - registryState registryState + settingsState settingsState + breakFile bool + breakFileContents bool wantError bool }{ - "Success": {registryState: s.registryHasValue}, - "Success when the key does not exist": {registryState: untouched}, - "Success when the value does not exist": {registryState: keyExists}, + "Success": {settingsState: landscapeUIDHasValue}, + "Success when the file does not exist": {settingsState: untouched}, + "Success when the value does not exist": {settingsState: fileExists}, - "Error when the registry key cannot be opened": {registryState: s.registryHasValue, mockErrors: registry.MockErrOnOpenKey, wantError: true}, - "Error when the registry key cannot be read from": {registryState: s.registryHasValue, mockErrors: registry.MockErrReadValue, wantError: true}, + "Error when the file cannot be opened": {settingsState: fileExists, breakFile: true, wantError: true}, + "Error when the file cannot be parsed": {settingsState: fileExists, breakFileContents: true, wantError: true}, } for name, tc := range testCases { @@ -144,25 +179,29 @@ func testConfigGetter(t *testing.T, s testConfigGetterSettings) { t.Parallel() ctx := context.Background() - r := setUpMockRegistry(tc.mockErrors, tc.registryState, false) - conf := config.New(ctx, config.WithRegistry(r)) + r, dir := setUpMockSettings(t, 0, tc.settingsState, false, tc.breakFile) + if tc.breakFileContents { + err := os.WriteFile(filepath.Join(dir, "config"), []byte("\tmessage:\n\t\tthis is not YAML!["), 0600) + require.NoError(t, err, "Setup: could not re-write config file") + } + conf := config.New(ctx, dir, config.WithRegistry(r)) - v, err := s.getter(conf, ctx) + v, err := conf.LandscapeAgentUID(ctx) if tc.wantError { - require.Error(t, err, "%s should return an error", s.getterName) + require.Error(t, err, "LandscapeAgentUID should return an error") return } - require.NoError(t, err, "%s should return no error", s.getterName) + require.NoError(t, err, "LandscapeAgentUID should return no error") // Test default values - if !tc.registryState.is(s.registryHasValue) { - require.Emptyf(t, v, "Unexpected value when %s is not set in registry", s.getterName) + if !tc.settingsState.is(landscapeUIDHasValue) { + require.Emptyf(t, v, "Unexpected value when LandscapeAgentUID is not set in registry") return } // Test non-default values - assert.Equalf(t, s.want, v, "%s returned an unexpected value", s.getterName) - assert.Zerof(t, r.OpenKeyCount.Load(), "Call to %s leaks registry keys", s.getterName) + assert.Equal(t, "landscapeUID1234", v, "LandscapeAgentUID returned an unexpected value") + assert.Zero(t, r.OpenKeyCount.Load(), "Call to LandscapeAgentUID leaks registry keys") }) } } @@ -172,21 +211,24 @@ func TestProvisioningTasks(t *testing.T) { testCases := map[string]struct { mockErrors uint32 - registryState registryState + settingsState settingsState + + wantToken string + wantLandscapeConf string + wantLandscapeUID string - want string wantNoLandscape bool wantError bool }{ - "Success when the key does not exist": {registryState: untouched}, - "Success when the pro token field does not exist": {registryState: keyExists}, - "Success when the pro token exists but is empty": {registryState: userTokenExists}, - "Success with a user token": {registryState: userTokenHasValue, want: "user_token"}, - "Success when there is Landscape config, but no UID": {registryState: landscapeClientConfigHasValue, wantNoLandscape: true}, - "Success when there is Landscape config and UID": {registryState: landscapeClientConfigHasValue | landscapeAgentUIDHasValue}, - - "Error when the registry key cannot be opened": {registryState: userTokenExists, mockErrors: registry.MockErrOnOpenKey, wantError: true}, - "Error when the registry key cannot be read from": {registryState: userTokenExists, mockErrors: registry.MockErrReadValue, wantError: true}, + "Success when the key does not exist": {settingsState: untouched}, + "Success when the pro token field does not exist": {settingsState: fileExists}, + "Success when the pro token exists but is empty": {settingsState: userTokenExists}, + "Success with a user token": {settingsState: userTokenHasValue, wantToken: "user_token"}, + "Success when there is Landscape config, but no UID": {settingsState: userLandscapeConfigHasValue, wantNoLandscape: true}, + "Success when there is Landscape config and UID": {settingsState: userLandscapeConfigHasValue | landscapeUIDHasValue, wantLandscapeConf: "[client]\nuser=JohnDoe", wantLandscapeUID: "landscapeUID1234"}, + + "Error when the registry key cannot be opened": {settingsState: orgTokenExists, mockErrors: registry.MockErrOnOpenKey, wantError: true}, + "Error when the registry key cannot be read from": {settingsState: orgTokenExists, mockErrors: registry.MockErrReadValue, wantError: true}, } for name, tc := range testCases { @@ -195,8 +237,8 @@ func TestProvisioningTasks(t *testing.T) { t.Parallel() ctx := context.Background() - r := setUpMockRegistry(tc.mockErrors, tc.registryState, false) - conf := config.New(ctx, config.WithRegistry(r)) + r, dir := setUpMockSettings(t, tc.mockErrors, tc.settingsState, false, false) + conf := config.New(ctx, dir, config.WithRegistry(r)) gotTasks, err := conf.ProvisioningTasks(ctx, "UBUNTU") if tc.wantError { @@ -206,13 +248,13 @@ func TestProvisioningTasks(t *testing.T) { require.NoError(t, err, "ProvisioningTasks should return no error") wantTasks := []task.Task{ - tasks.ProAttachment{Token: tc.want}, + tasks.ProAttachment{Token: tc.wantToken}, } if !tc.wantNoLandscape { wantTasks = append(wantTasks, tasks.LandscapeConfigure{ - Config: r.UbuntuProData["LandscapeClientConfig"], - HostagentUID: r.UbuntuProData["LandscapeAgentUID"], + Config: tc.wantLandscapeConf, + HostagentUID: tc.wantLandscapeUID, }) } @@ -221,29 +263,24 @@ func TestProvisioningTasks(t *testing.T) { } } -func TestSetSubscription(t *testing.T) { +func TestSetUserSubscription(t *testing.T) { t.Parallel() testCases := map[string]struct { - mockErrors uint32 - registryState registryState - accessIsReadOnly bool - emptyToken bool - - want string - wantError bool - wantErrorType error + settingsState settingsState + breakFile bool + emptyToken bool + + want string + wantError bool }{ - "Success": {registryState: userTokenHasValue, want: "new_token"}, - "Success disabling a subscription": {registryState: userTokenHasValue, emptyToken: true, want: ""}, - "Success when the key does not exist": {registryState: untouched, want: "new_token"}, - "Success when the pro token field does not exist": {registryState: keyExists, want: "new_token"}, - "Success when there is a store token active": {registryState: storeTokenHasValue, want: "store_token"}, + "Success": {settingsState: userTokenHasValue, want: "new_token"}, + "Success disabling a subscription": {settingsState: userTokenHasValue, emptyToken: true, want: ""}, + "Success when the key does not exist": {settingsState: untouched, want: "new_token"}, + "Success when the pro token field does not exist": {settingsState: keyExists, want: "new_token"}, + "Success when there is a store token active": {settingsState: storeTokenHasValue, want: "store_token"}, - "Error when the registry key cannot be written on due to lack of permission": {registryState: userTokenHasValue, accessIsReadOnly: true, want: "user_token", wantError: true, wantErrorType: registry.ErrAccessDenied}, - "Error when the registry key cannot be opened": {registryState: userTokenHasValue, mockErrors: registry.MockErrOnCreateKey, want: "user_token", wantError: true, wantErrorType: registry.ErrMock}, - "Error when the registry key cannot be written on": {registryState: userTokenHasValue, mockErrors: registry.MockErrOnWriteValue, want: "user_token", wantError: true, wantErrorType: registry.ErrMock}, - "Error when the registry key cannot be read": {registryState: userTokenHasValue, mockErrors: registry.MockErrOnOpenKey, want: "user_token", wantError: true, wantErrorType: registry.ErrMock}, + "Error when the file cannot be opened": {settingsState: fileExists, breakFile: true, wantError: true}, } for name, tc := range testCases { @@ -252,23 +289,20 @@ func TestSetSubscription(t *testing.T) { t.Parallel() ctx := context.Background() - r := setUpMockRegistry(tc.mockErrors, tc.registryState, tc.accessIsReadOnly) - conf := config.New(ctx, config.WithRegistry(r)) + r, dir := setUpMockSettings(t, 0, tc.settingsState, false, tc.breakFile) + conf := config.New(ctx, dir, config.WithRegistry(r)) token := "new_token" if tc.emptyToken { token = "" } - err := conf.SetSubscription(ctx, token, config.SubscriptionUser) + err := conf.SetUserSubscription(ctx, token) if tc.wantError { require.Error(t, err, "SetSubscription should return an error") - if tc.wantErrorType != nil { - require.ErrorIs(t, err, tc.wantErrorType, "SetSubscription returned an error of unexpected type") - } - } else { - require.NoError(t, err, "SetSubscription should return no error") + return } + require.NoError(t, err, "SetSubscription should return no error") // Disable errors so we can retrieve the token r.Errors = 0 @@ -284,24 +318,19 @@ func TestSetLandscapeAgentUID(t *testing.T) { t.Parallel() testCases := map[string]struct { - mockErrors uint32 - registryState registryState - accessIsReadOnly bool - emptyUID bool - - want string - wantError bool - wantErrorType error + settingsState settingsState + emptyUID bool + breakFile bool + + want string + wantError bool }{ - "Success": {registryState: landscapeAgentUIDHasValue, want: "new_uid"}, - "Success unsetting the UID": {registryState: landscapeAgentUIDHasValue, emptyUID: true, want: ""}, - "Success when the key does not exist": {registryState: untouched, want: "new_uid"}, - "Success when the pro token field does not exist": {registryState: keyExists, want: "new_uid"}, + "Success overriding the UID": {settingsState: landscapeUIDHasValue, want: "new_uid"}, + "Success unsetting the UID": {settingsState: landscapeUIDHasValue, emptyUID: true, want: ""}, + "Success when the file does not exist": {settingsState: untouched, want: "new_uid"}, + "Success when the pro token field does not exist": {settingsState: fileExists, want: "new_uid"}, - "Error when the registry key cannot be written on due to lack of permission": {registryState: landscapeAgentUIDHasValue, accessIsReadOnly: true, want: "landscapeUID1234", wantError: true, wantErrorType: registry.ErrAccessDenied}, - "Error when the registry key cannot be opened": {registryState: landscapeAgentUIDHasValue, mockErrors: registry.MockErrOnCreateKey, want: "landscapeUID1234", wantError: true, wantErrorType: registry.ErrMock}, - "Error when the registry key cannot be written on": {registryState: landscapeAgentUIDHasValue, mockErrors: registry.MockErrOnWriteValue, want: "landscapeUID1234", wantError: true, wantErrorType: registry.ErrMock}, - "Error when the registry key cannot be read": {registryState: landscapeAgentUIDHasValue, mockErrors: registry.MockErrOnOpenKey, want: "landscapeUID1234", wantError: true, wantErrorType: registry.ErrMock}, + "Error when the file cannot be opened": {settingsState: landscapeUIDHasValue, breakFile: true, want: "landscapeUID1234", wantError: true}, } for name, tc := range testCases { @@ -310,8 +339,8 @@ func TestSetLandscapeAgentUID(t *testing.T) { t.Parallel() ctx := context.Background() - r := setUpMockRegistry(tc.mockErrors, tc.registryState, tc.accessIsReadOnly) - conf := config.New(ctx, config.WithRegistry(r)) + r, dir := setUpMockSettings(t, 0, tc.settingsState, false, tc.breakFile) + conf := config.New(ctx, dir, config.WithRegistry(r)) uid := "new_uid" if tc.emptyUID { @@ -321,12 +350,9 @@ func TestSetLandscapeAgentUID(t *testing.T) { err := conf.SetLandscapeAgentUID(ctx, uid) if tc.wantError { require.Error(t, err, "SetLandscapeAgentUID should return an error") - if tc.wantErrorType != nil { - require.ErrorIs(t, err, tc.wantErrorType, "SetLandscapeAgentUID returned an error of unexpected type") - } - } else { - require.NoError(t, err, "SetLandscapeAgentUID should return no error") + return } + require.NoError(t, err, "SetLandscapeAgentUID should return no error") // Disable errors so we can retrieve the UID r.Errors = 0 @@ -342,15 +368,15 @@ func TestIsReadOnly(t *testing.T) { t.Parallel() testCases := map[string]struct { - registryState registryState + settingsState settingsState readOnly bool registryErr bool want bool wantErr bool }{ - "Success when the registry can be written on": {registryState: keyExists, want: false}, - "Success when the registry cannot be written on": {registryState: keyExists, readOnly: true, want: true}, + "Success when the registry can be written on": {settingsState: keyExists, want: false}, + "Success when the registry cannot be written on": {settingsState: keyExists, readOnly: true, want: true}, "Success when the non-existent registry can be written on": {want: false}, "Success when the non-existent registry cannot be written on": {readOnly: true, want: true}, @@ -364,12 +390,12 @@ func TestIsReadOnly(t *testing.T) { t.Parallel() ctx := context.Background() - r := setUpMockRegistry(0, tc.registryState, tc.readOnly) + r, dir := setUpMockSettings(t, 0, tc.settingsState, tc.readOnly, false) if tc.registryErr { r.Errors = registry.MockErrOnCreateKey } - conf := config.New(ctx, config.WithRegistry(r)) + conf := config.New(ctx, dir, config.WithRegistry(r)) got, err := conf.IsReadOnly() if tc.wantErr { @@ -387,7 +413,7 @@ func TestFetchMicrosoftStoreSubscription(t *testing.T) { t.Parallel() testCases := map[string]struct { - registryState registryState + settingsState settingsState registryErr uint32 registryIsReadOnly bool @@ -395,7 +421,7 @@ func TestFetchMicrosoftStoreSubscription(t *testing.T) { wantErr bool }{ // TODO: Implement more test cases when the MS Store mock is available. There is no single successful test in here so far. - "Error when registry is read only": {registryState: userTokenHasValue, registryIsReadOnly: true, wantToken: "user_token", wantErr: true}, + "Error when registry is read only": {settingsState: userTokenHasValue, registryIsReadOnly: true, wantToken: "user_token", wantErr: true}, "Error when registry read-only check fails": {registryErr: registry.MockErrOnCreateKey, wantErr: true}, // Stub test-case: Must be replaced with Success/Error return values of contracts.ProToken @@ -410,8 +436,8 @@ func TestFetchMicrosoftStoreSubscription(t *testing.T) { ctx := context.Background() - r := setUpMockRegistry(tc.registryErr, tc.registryState, tc.registryIsReadOnly) - c := config.New(ctx, config.WithRegistry(r)) + r, dir := setUpMockSettings(t, tc.registryErr, tc.settingsState, tc.registryIsReadOnly, false) + c := config.New(ctx, dir, config.WithRegistry(r)) err := c.FetchMicrosoftStoreSubscription(ctx) if tc.wantErr { @@ -436,20 +462,20 @@ func TestUpdateRegistrySettings(t *testing.T) { testCases := map[string]struct { valueToChange string - registryState registryState + settingsState settingsState 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"}}, + "Success changing Pro token": {valueToChange: "UbuntuProToken", settingsState: keyExists, wantTasks: []string{"tasks.ProAttachment"}, unwantedTasks: []string{"tasks.LandscapeConfigure"}}, + "Success changing Landscape without a UID": {valueToChange: "LandscapeConfig", settingsState: keyExists, unwantedTasks: []string{"tasks.ProAttachment", "tasks.LandscapeConfigure"}}, + "Success changing Landscape with a UID": {valueToChange: "LandscapeConfig", settingsState: landscapeUIDHasValue, wantTasks: []string{"tasks.LandscapeConfigure"}, unwantedTasks: []string{"tasks.ProAttachment"}}, + "Success changing the Landscape UID": {valueToChange: "LandscapeUID", settingsState: 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}, + "Error when the tasks cannot be submitted": {valueToChange: "UbuntuProToken", settingsState: keyExists, breakTaskfile: true, wantErr: true}, } for name, tc := range testCases { @@ -472,20 +498,25 @@ func TestUpdateRegistrySettings(t *testing.T) { _, 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) + r, dir := setUpMockSettings(t, 0, tc.settingsState, false, false) require.NoError(t, err, "Setup: could not create empty database") - c := config.New(ctx, config.WithRegistry(r)) + c := config.New(ctx, dir, config.WithRegistry(r)) - // Update value in registry - r.UbuntuProData[tc.valueToChange] = "NEW_VALUE!" + // Update value in registry or in config + if tc.valueToChange == "LandscapeUID" { + err := c.SetLandscapeAgentUID(ctx, "NEW_UID!") + require.NoError(t, err, "Setup: could not update Landscape UID") + } else { + 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) + err = c.UpdateRegistrySettings(ctx, db) if tc.wantErr { require.Error(t, err, "UpdateRegistrySettings should return an error") return @@ -512,55 +543,90 @@ func readFileOrEmpty(path string) (string, error) { 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 { +// is defines equality between flags. It is convenience function to check if a settingsState matches a certain state. +func (state settingsState) is(flag settingsState) bool { return state&flag == flag } -func setUpMockRegistry(mockErrors uint32, state registryState, readOnly bool) *registry.Mock { - r := registry.NewMock() +func setUpMockSettings(t *testing.T, mockErrors uint32, state settingsState, readOnly bool, fileBroken bool) (*registry.Mock, string) { + t.Helper() - r.Errors = mockErrors - r.KeyIsReadOnly = readOnly + // Mock registry + reg := registry.NewMock() + reg.Errors = mockErrors + reg.KeyIsReadOnly = readOnly if state.is(keyExists) { - r.KeyExists = true + reg.KeyExists = true } if state.is(orgTokenExists) { - r.UbuntuProData["ProTokenOrg"] = "" + reg.UbuntuProData["UbuntuProToken"] = "" } if state.is(orgTokenHasValue) { - r.UbuntuProData["ProTokenOrg"] = "org_token" + reg.UbuntuProData["UbuntuProToken"] = "org_token" + } + + if state.is(orgLandscapeConfigExists) { + reg.UbuntuProData["LandscapeConfig"] = "" + } + if state.is(orgLandscapeConfigHasValue) { + reg.UbuntuProData["LandscapeConfig"] = "[client]\nuser=BigOrg" + } + + // Mock file config + cacheDir := t.TempDir() + if fileBroken { + err := os.MkdirAll(filepath.Join(cacheDir, "config"), 0600) + require.NoError(t, err, "Setup: could not create directory to interfere with config") + return reg, cacheDir + } + + if !state.is(fileExists) { + return reg, cacheDir + } + + fileData := struct { + Landscape map[string]string + Subscription map[string]string + }{ + Subscription: make(map[string]string), + Landscape: make(map[string]string), } if state.is(userTokenExists) { - r.UbuntuProData["ProTokenUser"] = "" + fileData.Subscription["user"] = "" } if state.is(userTokenHasValue) { - r.UbuntuProData["ProTokenUser"] = "user_token" + fileData.Subscription["user"] = "user_token" } if state.is(storeTokenExists) { - r.UbuntuProData["ProTokenStore"] = "" + fileData.Subscription["store"] = "" } if state.is(storeTokenHasValue) { - r.UbuntuProData["ProTokenStore"] = "store_token" + fileData.Subscription["store"] = "store_token" } - if state.is(landscapeClientConfigExists) { - r.UbuntuProData["LandscapeClientConfig"] = "" + if state.is(userLandscapeConfigExists) { + fileData.Landscape["config"] = "" } - if state.is(landscapeClientConfigHasValue) { - r.UbuntuProData["LandscapeClientConfig"] = "[client]\nuser=JohnDoe" + if state.is(userLandscapeConfigHasValue) { + fileData.Landscape["config"] = "[client]\nuser=JohnDoe" } - if state.is(landscapeAgentUIDExists) { - r.UbuntuProData["LandscapeAgentUID"] = "" + if state.is(landscapeUIDExists) { + fileData.Landscape["uid"] = "" } - if state.is(landscapeAgentUIDHasValue) { - r.UbuntuProData["LandscapeAgentUID"] = "landscapeUID1234" + if state.is(landscapeUIDHasValue) { + fileData.Landscape["uid"] = "landscapeUID1234" } - return r + out, err := yaml.Marshal(fileData) + require.NoError(t, err, "Setup: could not marshal fake config") + + err = os.WriteFile(filepath.Join(cacheDir, "config"), out, 0600) + require.NoError(t, err, "Setup: could not write config file") + + return reg, cacheDir } diff --git a/windows-agent/internal/proservices/landscape/landscape.go b/windows-agent/internal/proservices/landscape/landscape.go index ceb0f6a02..7ca080449 100644 --- a/windows-agent/internal/proservices/landscape/landscape.go +++ b/windows-agent/internal/proservices/landscape/landscape.go @@ -54,9 +54,9 @@ type connection struct { // Config is a configuration provider for ProToken and the Landscape URL. type Config interface { - LandscapeClientConfig(context.Context) (string, error) + LandscapeClientConfig(context.Context) (string, config.Source, error) - Subscription(context.Context) (string, config.SubscriptionSource, error) + Subscription(context.Context) (string, config.Source, error) LandscapeAgentUID(context.Context) (string, error) SetLandscapeAgentUID(context.Context, string) error @@ -97,7 +97,7 @@ func NewClient(conf Config, db *database.DistroDB, args ...Option) (*Client, err // hostagentURL parses the landscape config file to find the hostagent URL. func (c *Client) hostagentURL(ctx context.Context) (string, error) { - config, err := c.conf.LandscapeClientConfig(ctx) + config, _, err := c.conf.LandscapeClientConfig(ctx) if err != nil { return "", err } @@ -370,7 +370,7 @@ func (conn *connection) connected() bool { func (c *Client) transportCredentials(ctx context.Context) (cred credentials.TransportCredentials, err error) { defer decorate.OnError(&err, "Landscape credentials") - conf, err := c.conf.LandscapeClientConfig(ctx) + conf, _, err := c.conf.LandscapeClientConfig(ctx) if err != nil { return nil, fmt.Errorf("could not obtain Landscape config: %v", err) } diff --git a/windows-agent/internal/proservices/landscape/landscape_test.go b/windows-agent/internal/proservices/landscape/landscape_test.go index d6fc1419a..7505cb85f 100644 --- a/windows-agent/internal/proservices/landscape/landscape_test.go +++ b/windows-agent/internal/proservices/landscape/landscape_test.go @@ -798,28 +798,28 @@ type mockConfig struct { mu sync.Mutex } -func (m *mockConfig) LandscapeClientConfig(ctx context.Context) (string, error) { +func (m *mockConfig) LandscapeClientConfig(ctx context.Context) (string, config.Source, error) { m.mu.Lock() defer m.mu.Unlock() if m.landscapeConfigErr { - return "", errors.New("Mock error") + return "", config.SourceNone, errors.New("Mock error") } - return m.landscapeClientConfig, nil + return m.landscapeClientConfig, config.SourceUser, nil } func (m *mockConfig) ProvisioningTasks(ctx context.Context, distroName string) ([]task.Task, error) { return nil, nil } -func (m *mockConfig) Subscription(ctx context.Context) (string, config.SubscriptionSource, error) { +func (m *mockConfig) Subscription(ctx context.Context) (string, config.Source, error) { m.mu.Lock() defer m.mu.Unlock() if m.proTokenErr { - return "", config.SubscriptionNone, errors.New("Mock error") + return "", config.SourceNone, errors.New("Mock error") } - return m.proToken, config.SubscriptionUser, nil + return m.proToken, config.SourceUser, nil } func (m *mockConfig) LandscapeAgentUID(ctx context.Context) (string, error) { diff --git a/windows-agent/internal/proservices/proservices.go b/windows-agent/internal/proservices/proservices.go index 84f17c59d..79f8f5039 100644 --- a/windows-agent/internal/proservices/proservices.go +++ b/windows-agent/internal/proservices/proservices.go @@ -81,7 +81,7 @@ func New(ctx context.Context, args ...Option) (s Manager, err error) { return s, err } - conf := config.New(ctx, config.WithRegistry(opts.registry)) + conf := config.New(ctx, opts.cacheDir, config.WithRegistry(opts.registry)) if err := conf.FetchMicrosoftStoreSubscription(ctx); err != nil { log.Warningf(ctx, "%v", err) } @@ -98,7 +98,7 @@ func New(ctx context.Context, args ...Option) (s Manager, err error) { } }() - if err := conf.UpdateRegistrySettings(ctx, opts.cacheDir, db); err != nil { + if err := conf.UpdateRegistrySettings(ctx, db); err != nil { log.Warningf(ctx, "Could not update registry settings: %v", err) } diff --git a/windows-agent/internal/proservices/ui/ui.go b/windows-agent/internal/proservices/ui/ui.go index a1a92f9fd..7a032f7a1 100644 --- a/windows-agent/internal/proservices/ui/ui.go +++ b/windows-agent/internal/proservices/ui/ui.go @@ -16,9 +16,9 @@ import ( // Config is a provider for the subcription configuration. type Config interface { - SetSubscription(ctx context.Context, token string, source config.SubscriptionSource) error + SetUserSubscription(ctx context.Context, token string) error IsReadOnly() (bool, error) - Subscription(context.Context) (string, config.SubscriptionSource, error) + Subscription(context.Context) (string, config.Source, error) FetchMicrosoftStoreSubscription(context.Context) error } @@ -45,7 +45,7 @@ func (s *Service) ApplyProToken(ctx context.Context, info *agentapi.ProAttachInf token := info.GetToken() log.Debugf(ctx, "Received token %s", common.Obfuscate(token)) - err := s.config.SetSubscription(ctx, token, config.SubscriptionUser) + err := s.config.SetUserSubscription(ctx, token) if err != nil { return nil, err } @@ -87,13 +87,13 @@ func (s *Service) GetSubscriptionInfo(ctx context.Context, empty *agentapi.Empty } switch source { - case config.SubscriptionNone: + case config.SourceNone: info.SubscriptionType = &agentapi.SubscriptionInfo_None{} - case config.SubscriptionUser: + case config.SourceUser: info.SubscriptionType = &agentapi.SubscriptionInfo_User{} - case config.SubscriptionOrganization: + case config.SourceRegistry: info.SubscriptionType = &agentapi.SubscriptionInfo_Organization{} - case config.SubscriptionMicrosoftStore: + case config.SourceMicrosoftStore: info.SubscriptionType = &agentapi.SubscriptionInfo_MicrosoftStore{} default: return nil, fmt.Errorf("unrecognized subscription source: %d", source) diff --git a/windows-agent/internal/proservices/ui/ui_test.go b/windows-agent/internal/proservices/ui/ui_test.go index e4209ddf8..7f591006d 100644 --- a/windows-agent/internal/proservices/ui/ui_test.go +++ b/windows-agent/internal/proservices/ui/ui_test.go @@ -3,6 +3,9 @@ package ui_test import ( "context" "errors" + "fmt" + "os" + "path/filepath" "testing" agentapi "github.com/canonical/ubuntu-pro-for-windows/agentapi/go" @@ -26,7 +29,7 @@ func TestNew(t *testing.T) { require.NoError(t, err, "Setup: empty database New() should return no error") defer db.Close(ctx) - conf := config.New(ctx, config.WithRegistry(registry.NewMock())) + conf := config.New(ctx, dir, config.WithRegistry(registry.NewMock())) _ = ui.New(context.Background(), conf, db) } @@ -45,9 +48,9 @@ func TestAttachPro(t *testing.T) { distro2, _ := wsltestutils.RegisterDistro(t, ctx, false) testCases := map[string]struct { - distros []string - token string - registryReadOnly bool + distros []string + token string + breakConfig bool wantErr bool }{ @@ -55,7 +58,7 @@ func TestAttachPro(t *testing.T) { "Success with an empty database": {token: "funny_token"}, "Success with a non-empty database": {token: "whatever_token", distros: []string{distro1, distro2}}, - "Error due to no write permission on token": {registryReadOnly: true, wantErr: true}, + "Error when the config cannot write": {breakConfig: true, wantErr: true}, } for name, tc := range testCases { @@ -78,11 +81,17 @@ func TestAttachPro(t *testing.T) { const originalToken = "old_token" m := registry.NewMock() - m.KeyIsReadOnly = tc.registryReadOnly - m.KeyExists = true - m.UbuntuProData["ProTokenUser"] = originalToken - conf := config.New(ctx, config.WithRegistry(m)) + if tc.breakConfig { + err := os.MkdirAll(filepath.Join(dir, "config"), 0600) + require.NoError(t, err, "Setup: could not create directory to interfere with config") + } else { + contents := fmt.Sprintf("subscription:\n gui: %s", originalToken) + err = os.WriteFile(filepath.Join(dir, "config"), []byte(contents), 0600) + require.NoError(t, err, "Setup: could not write config file") + } + + conf := config.New(ctx, dir, config.WithRegistry(m)) serv := ui.New(context.Background(), conf, db) info := agentapi.ProAttachInfo{Token: tc.token} @@ -91,11 +100,10 @@ func TestAttachPro(t *testing.T) { var wantToken string if tc.wantErr { require.Error(t, err, "Unexpected success in ApplyProToken") - wantToken = originalToken - } else { - require.NoError(t, err, "Adding the task to existing distros should succeed.") - wantToken = tc.token + return } + require.NoError(t, err, "Adding the task to existing distros should succeed.") + wantToken = tc.token token, _, err := conf.Subscription(ctx) require.NoError(t, err, "conf.ProToken should return no error") @@ -121,17 +129,17 @@ func TestGetSubscriptionInfo(t *testing.T) { wantImmutable bool wantErr bool }{ - "Success with a non-subscription": {config: mockConfig{source: config.SubscriptionNone}, wantType: none}, - "Success with a read-only non-subscription": {config: mockConfig{source: config.SubscriptionNone, registryReadOnly: true}, wantType: none, wantImmutable: true}, + "Success with a non-subscription": {config: mockConfig{source: config.SourceNone}, wantType: none}, + "Success with a read-only non-subscription": {config: mockConfig{source: config.SourceNone, registryReadOnly: true}, wantType: none, wantImmutable: true}, - "Success with an organization subscription": {config: mockConfig{source: config.SubscriptionOrganization}, wantType: organization}, - "Success with a read-only organization subscription": {config: mockConfig{source: config.SubscriptionOrganization, registryReadOnly: true}, wantType: organization, wantImmutable: true}, + "Success with an organization subscription": {config: mockConfig{source: config.SourceRegistry}, wantType: organization}, + "Success with a read-only organization subscription": {config: mockConfig{source: config.SourceRegistry, registryReadOnly: true}, wantType: organization, wantImmutable: true}, - "Success with a user subscription": {config: mockConfig{source: config.SubscriptionUser}, wantType: user}, - "Success with a read-only user subscription": {config: mockConfig{source: config.SubscriptionUser, registryReadOnly: true}, wantType: user, wantImmutable: true}, + "Success with a user subscription": {config: mockConfig{source: config.SourceUser}, wantType: user}, + "Success with a read-only user subscription": {config: mockConfig{source: config.SourceUser, registryReadOnly: true}, wantType: user, wantImmutable: true}, - "Success with a store subscription": {config: mockConfig{source: config.SubscriptionMicrosoftStore}, wantType: store}, - "Success with a read-only store subscription": {config: mockConfig{source: config.SubscriptionMicrosoftStore, registryReadOnly: true}, wantType: store, wantImmutable: true}, + "Success with a store subscription": {config: mockConfig{source: config.SourceMicrosoftStore}, wantType: store}, + "Success with a read-only store subscription": {config: mockConfig{source: config.SourceMicrosoftStore, registryReadOnly: true}, wantType: store, wantImmutable: true}, "Error when the read-only check fails": {config: mockConfig{isReadOnlyErr: true}, wantErr: true}, "Error when the subscription cannot be retreived": {config: mockConfig{subscriptionErr: true}, wantErr: true}, @@ -171,14 +179,14 @@ func TestNotifyPurchase(t *testing.T) { wantImmutable bool wantErr bool }{ - "Success with a non-subscription": {config: mockConfig{source: config.SubscriptionNone}, wantType: store}, - "Success with an existing user subscription": {config: mockConfig{source: config.SubscriptionUser}, wantType: store}, - - "Error to fetch MS Store": {config: mockConfig{source: config.SubscriptionNone, fetchErr: true}, wantType: none, wantErr: true}, - "Error to set the subscription": {config: mockConfig{source: config.SubscriptionNone, setSubscriptionErr: true}, wantType: none, wantErr: true}, - "Error to read the registry": {config: mockConfig{source: config.SubscriptionNone, isReadOnlyErr: true}, wantType: none, wantErr: true}, - "Error with an existing store subscription": {config: mockConfig{source: config.SubscriptionMicrosoftStore}, wantType: store, wantErr: true}, - "Error with a read-only organization subscription": {config: mockConfig{source: config.SubscriptionOrganization, registryReadOnly: true}, wantType: organization, wantImmutable: true, wantErr: true}, + "Success with a non-subscription": {config: mockConfig{source: config.SourceNone}, wantType: store}, + "Success with an existing user subscription": {config: mockConfig{source: config.SourceUser}, wantType: store}, + + "Error to fetch MS Store": {config: mockConfig{source: config.SourceNone, fetchErr: true}, wantType: none, wantErr: true}, + "Error to set the subscription": {config: mockConfig{source: config.SourceNone, setSubscriptionErr: true}, wantType: none, wantErr: true}, + "Error to read the registry": {config: mockConfig{source: config.SourceNone, isReadOnlyErr: true}, wantType: none, wantErr: true}, + "Error with an existing store subscription": {config: mockConfig{source: config.SourceMicrosoftStore}, wantType: store, wantErr: true}, + "Error with a read-only organization subscription": {config: mockConfig{source: config.SourceRegistry, registryReadOnly: true}, wantType: organization, wantImmutable: true, wantErr: true}, } for name, tc := range testCases { @@ -212,16 +220,16 @@ type mockConfig struct { subscriptionErr bool // Config errors out in Subscription function fetchErr bool // Config errors out in FetchMicrosoftStoreSubscription function - token string // stores the configured Pro token - source config.SubscriptionSource // stores the configured subscription source. + token string // stores the configured Pro token + source config.Source // stores the configured subscription source. } -func (m *mockConfig) SetSubscription(ctx context.Context, token string, source config.SubscriptionSource) error { +func (m *mockConfig) SetUserSubscription(ctx context.Context, token string) error { if m.setSubscriptionErr { return errors.New("SetSubscription error") } m.token = token - m.source = source + m.source = config.SourceUser return nil } func (m mockConfig) IsReadOnly() (bool, error) { @@ -230,9 +238,9 @@ func (m mockConfig) IsReadOnly() (bool, error) { } return m.registryReadOnly, nil } -func (m mockConfig) Subscription(context.Context) (string, config.SubscriptionSource, error) { +func (m mockConfig) Subscription(context.Context) (string, config.Source, error) { if m.subscriptionErr { - return "", config.SubscriptionNone, errors.New("Subscription error") + return "", config.SourceNone, errors.New("Subscription error") } return m.token, m.source, nil } @@ -247,9 +255,16 @@ func (m *mockConfig) FetchMicrosoftStoreSubscription(ctx context.Context) error if m.fetchErr { return errors.New("FetchMicrosoftStoreSubscription error") } - if m.source == config.SubscriptionMicrosoftStore { + if m.source == config.SourceMicrosoftStore { return errors.New("Already subscribed") } - return m.SetSubscription(ctx, "MS", config.SubscriptionMicrosoftStore) + if m.setSubscriptionErr { + return errors.New("SetSubscription error") + } + + m.token = "MS" + m.source = config.SourceMicrosoftStore + + return nil }