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(internal/consent): UDENG-5705 ubuntu insights consent manager #3

Merged
merged 27 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
85ff109
Implement consent manager GetConsentStates and associated tests.
hk21702 Jan 10, 2025
c924bb2
Add test for Run in main.go
hk21702 Jan 7, 2025
cb7791b
Change collect metrics path flag to be an argument, update collect ar…
hk21702 Jan 9, 2025
f584a4f
Add consent manager state set as well as associated tests.
hk21702 Jan 10, 2025
138c731
Merge branch 'UDENG-5696-initial-client-core-cli' into UDENG-5705-Ubu…
hk21702 Jan 10, 2025
ede5882
Add package documentation comments for clarity and improve error mess…
hk21702 Jan 13, 2025
534bcc3
Merge branch 'UDENG-5696-initial-client-core-cli' into UDENG-5705-Ubu…
hk21702 Jan 13, 2025
991b872
Merge branch 'UDENG-5696-initial-client-core-cli' into UDENG-5705-Ubu…
hk21702 Jan 13, 2025
a4f694b
Remove flock, change consent write to be atomic, measure file count i…
hk21702 Jan 13, 2025
8d7e5ab
Re-add golangci
hk21702 Jan 13, 2025
c437b4b
Fix constants test to pass on windows
hk21702 Jan 14, 2025
aeff7af
Linter fixes
hk21702 Jan 14, 2025
009678f
Add dependabot
hk21702 Jan 15, 2025
e2c7fc5
Fix testdata whitespace
hk21702 Jan 15, 2025
0697040
Add GitHub Actions workflows for code checks and QA
hk21702 Jan 14, 2025
4be084b
Add to golangci linters and exclusions.
hk21702 Jan 14, 2025
d0ab6c0
Minor linting fixes
hk21702 Jan 14, 2025
aedb2a8
Add gitatributes file, convert eol to lf
hk21702 Jan 14, 2025
cbf6eda
replace actions from fork
hk21702 Jan 15, 2025
d2e6c77
Update QA workflow to use 'set -euo pipefail' for improved error hand…
hk21702 Jan 15, 2025
962c8cb
Enhance consent component with improved logging and error messages
hk21702 Jan 16, 2025
43bdab3
Refactor consent component
hk21702 Jan 16, 2025
6160af7
Refactor consent manager to simplify functions
hk21702 Jan 16, 2025
0990985
Add mac and windows sysinfo bones
Sploder12 Jan 16, 2025
1c7b560
Apply linting
Sploder12 Jan 16, 2025
52d0aa9
Merge branch 'main' into UDENG-5705-Ubuntu-Insights-Consent-Manager
hk21702 Jan 21, 2025
5ad0e21
Factor out consent test file helper functions into testutils
hk21702 Jan 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ module github.com/ubuntu/ubuntu-insights
go 1.23.4

require (
github.com/BurntSushi/toml v1.4.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.10.0
github.com/ubuntu/decorate v0.0.0-20240820145549-b76bb81d1209
gopkg.in/yaml.v3 v3.0.1
)

Expand All @@ -14,5 +16,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/sys v0.3.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
22 changes: 20 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ubuntu/decorate v0.0.0-20240820145549-b76bb81d1209 h1:paNkjGGwB/Ypory/EPTwVR5uX94TDgrH4PGSaCNAvhE=
github.com/ubuntu/decorate v0.0.0-20240820145549-b76bb81d1209/go.mod h1:PUpwIgUuCQyuCz/gwiq6WYbo7IvtXXd8JqL01ez+jZE=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
130 changes: 130 additions & 0 deletions internal/consent/consent.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,133 @@
// Package consent is the implementation of the consent manager component.
// The consent manager is responsible for managing consent files, which are used to store the consent state for a source or the global consent state.
package consent

import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"

"github.com/BurntSushi/toml"
"github.com/ubuntu/decorate"
"github.com/ubuntu/ubuntu-insights/internal/constants"
)

// Manager is a struct that manages consent files.
type Manager struct {
path string
}

// consentFile is a struct that represents a consent file.
type consentFile struct {
ConsentState bool `toml:"consent_state"`
}

// New returns a new ConsentManager.
// path is the folder the consents are stored into.
func New(path string) *Manager {
return &Manager{path: path}
}

// GetConsentState gets the consent state for the given source.
// If the source do not have a consent file, it will be considered as a false state.
// If the source is an empty string, then the global consent state will be returned.
// If the target consent file does not exist, it will not be created.
func (cm Manager) GetConsentState(source string) (bool, error) {
sourceConsent, err := readConsentFile(cm.getConsentFile(source))
if err != nil {
slog.Error("Error reading source consent file", "source", source, "error", err)
return false, err
}

return sourceConsent.ConsentState, nil
}

var consentSourceFilePattern = `%s` + constants.ConsentSourceBaseSeparator + constants.GlobalFileName

// SetConsentState updates the consent state for the given source.
// If the source is an empty string, then the global consent state will be set.
// If the target consent file does not exist, it will be created.
func (cm Manager) SetConsentState(source string, state bool) (err error) {
defer decorate.OnError(&err, "could not set consent state")

consent := consentFile{ConsentState: state}
return consent.write(cm.getConsentFile(source))
}

// getConsentFile returns the expected path to the consent file for the given source.
// If source is blank, it returns the path to the global consent file.
// It does not check if the file exists, or if it is valid.
func (cm Manager) getConsentFile(source string) string {
p := filepath.Join(cm.path, constants.GlobalFileName)
if source != "" {
p = filepath.Join(cm.path, fmt.Sprintf(consentSourceFilePattern, source))
}

return p
}

// getSourceConsentFiles returns a map of all paths to validly named consent files in the folder, other than the global file.
func (cm Manager) getConsentFiles() (map[string]string, error) {
sourceFiles := make(map[string]string)

entries, err := os.ReadDir(cm.path)
if err != nil {
return sourceFiles, err
}

for _, entry := range entries {
if entry.IsDir() {
continue
}

// Source file
if !strings.HasSuffix(entry.Name(), constants.ConsentSourceBaseSeparator+constants.GlobalFileName) {
continue
}
source := strings.TrimSuffix(entry.Name(), constants.ConsentSourceBaseSeparator+constants.GlobalFileName)
sourceFiles[source] = filepath.Join(cm.path, entry.Name())
slog.Debug("Found source consent file", "file", sourceFiles[source])
}

return sourceFiles, nil
}

func readConsentFile(path string) (consentFile, error) {
var consent consentFile
_, err := toml.DecodeFile(path, &consent)
slog.Debug("Read consent file", "file", path, "consent", consent.ConsentState)

return consent, err
}

// writeConsentFile writes the given consent file to the given path atomically, replacing it if it already exists.
// Not atomic on Windows.
func (cf consentFile) write(path string) (err error) {
tmp, err := os.CreateTemp(filepath.Dir(path), "consent-*.tmp")
if err != nil {
return fmt.Errorf("could not create temporary file: %v", err)
}
defer func() {
_ = tmp.Close()
if err := os.Remove(tmp.Name()); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to remove temporary file when writing consent file", "file", tmp.Name(), "error", err)
}
}()

if err := toml.NewEncoder(tmp).Encode(cf); err != nil {
return fmt.Errorf("could not encode consent file: %v", err)
}

if err := tmp.Close(); err != nil {
return fmt.Errorf("could not close temporary file: %v", err)
}

if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("could not rename temporary file: %v", err)
}
slog.Debug("Wrote consent file", "file", path, "consent", cf.ConsentState)

return nil
}
160 changes: 160 additions & 0 deletions internal/consent/consent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package consent_test

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
"github.com/ubuntu/ubuntu-insights/internal/consent"
"github.com/ubuntu/ubuntu-insights/internal/testutils"
)

func TestGetConsentState(t *testing.T) {
t.Parallel()

tests := map[string]struct {
source string
globalFile string

wantErr bool
}{
"No Global File": {wantErr: true},

// Global File Tests
"Valid True Global File": {globalFile: "valid_true-consent.toml"},
"Valid False Global File": {globalFile: "valid_false-consent.toml"},
"Invalid Value Global File": {globalFile: "invalid_value-consent.toml", wantErr: true},
"Invalid File Global File": {globalFile: "invalid_file-consent.toml", wantErr: true},

// Source Specific Tests
"Valid True Global File, Valid True Source": {globalFile: "valid_true-consent.toml", source: "valid_true"},
"Valid True Global File, Valid False Source": {globalFile: "valid_true-consent.toml", source: "valid_false"},
"Valid True Global File, Invalid Value Source": {globalFile: "valid_true-consent.toml", source: "invalid_value", wantErr: true},
"Valid True Global File, Invalid File Source": {globalFile: "valid_true-consent.toml", source: "invalid_file", wantErr: true},
"Valid True Global File, No File Source": {globalFile: "valid_true-consent.toml", source: "not_a_file", wantErr: true},
Sploder12 marked this conversation as resolved.
Show resolved Hide resolved

// Invalid Global File, Source Specific Tests
"Invalid Value Global File, Valid True Source": {globalFile: "invalid_value-consent.toml", source: "valid_true"},
"Invalid Value Global File, Valid False Source": {globalFile: "invalid_value-consent.toml", source: "valid_false"},
"Invalid Value Global File, Invalid Value Source": {globalFile: "invalid_value-consent.toml", source: "invalid_value", wantErr: true},
"Invalid Value Global File, Invalid File Source": {globalFile: "invalid_value-consent.toml", source: "invalid_file", wantErr: true},
"Invalid Value Global File, No File Source": {globalFile: "invalid_value-consent.toml", source: "not_a_file", wantErr: true},

// No Global File, Source Specific Tests
"No Global File, Valid True Source": {source: "valid_true"},
"No Global File, Valid False Source": {source: "valid_false"},
"No Global File, Invalid Value Source": {source: "invalid_value", wantErr: true},
"No Global File, Invalid File Source": {source: "invalid_file", wantErr: true},
"No Global File, No File Source": {source: "not_a_file", wantErr: true},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
dir, err := setupTmpConsentFiles(t, tc.globalFile)
require.NoError(t, err, "Setup: failed to setup temporary consent files")
defer testutils.CleanupDir(t, dir)
cm := consent.New(dir)

got, err := cm.GetConsentState(tc.source)
if tc.wantErr {
require.Error(t, err, "expected an error but got none")
return
}
require.NoError(t, err, "got an unexpected error")

want := testutils.LoadWithUpdateFromGoldenYAML(t, got)
require.Equal(t, want, got, "GetConsentState should return expected consent state")
})
}
}

func TestSetConsentStates(t *testing.T) {
t.Parallel()

tests := map[string]struct {
consentStates map[string]bool
globalFile string

writeSource string
writeState bool

wantErr bool
}{
// New File Tests
"New File, Write Global False": {},
"New File, Write Global True": {writeState: true},
"New File, Write Source True": {writeSource: "new_true", writeState: true},
"New File, Write Source False": {writeSource: "new_false"},

// Overwrite File, Different State
"Overwrite File, Write Diff Global False": {globalFile: "valid_true-consent.toml", writeState: false},
"Overwrite File, Write Diff Global True": {globalFile: "valid_false-consent.toml", writeState: true},
"Overwrite File, Write Diff Source True": {globalFile: "valid_true-consent.toml", writeSource: "valid_false", writeState: true},
"Overwrite File, Write Diff Source False": {globalFile: "valid_true-consent.toml", writeSource: "valid_true", writeState: false},

// Overwrite File, Same State
"Overwrite File, Write Global True": {globalFile: "valid_true-consent.toml", writeState: true},
"Overwrite File, Write Global False": {globalFile: "valid_false-consent.toml", writeState: false},
"Overwrite File, Write Source True": {globalFile: "valid_true-consent.toml", writeSource: "valid_true", writeState: true},
"Overwrite File, Write Source False": {globalFile: "valid_false-consent.toml", writeSource: "valid_false", writeState: false},
}

type goldenFile struct {
States map[string]bool
FileCount int
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
dir, err := setupTmpConsentFiles(t, tc.globalFile)
require.NoError(t, err, "Setup: failed to setup temporary consent files")
defer testutils.CleanupDir(t, dir)
cm := consent.New(dir)

err = cm.SetConsentState(tc.writeSource, tc.writeState)
if tc.wantErr {
require.Error(t, err, "expected an error but got none")
return
}
require.NoError(t, err, "got an unexpected error")

states, err := cm.GetAllSourceConsentStates(true)
require.NoError(t, err, "got an unexpected error while getting consent states")

d, err := os.ReadDir(dir)
require.NoError(t, err, "failed to read temporary directory")
got := goldenFile{States: states, FileCount: len(d)}

want := testutils.LoadWithUpdateFromGoldenYAML(t, got)
require.Equal(t, want, got, "GetConsentStates should return expected consent states")
})
}
}

func setupTmpConsentFiles(t *testing.T, globalFile string) (string, error) {
t.Helper()

// Setup temporary directory
var err error
dir, err := os.MkdirTemp("", "consent-files")
if err != nil {
return dir, fmt.Errorf("failed to create temporary directory: %v", err)
}

if err = testutils.CopyDir(filepath.Join("testdata", "consent_files"), dir); err != nil {
return dir, fmt.Errorf("failed to copy testdata directory to temporary directory: %v", err)
}

// Setup globalFile if provided
if globalFile != "" {
if err = testutils.CopyFile(filepath.Join(dir, globalFile), filepath.Join(dir, "consent.toml")); err != nil {
return dir, fmt.Errorf("failed to copy requested global consent file: %v", err)
}
}

return dir, nil
}
26 changes: 26 additions & 0 deletions internal/consent/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package consent

// GetAllSourceConsentStates gets the consent states for all sources.
// It does not get the global consent state.
// If continueOnErr is true, it will continue to the next source if an error occurs.
func (cm Manager) GetAllSourceConsentStates(continueOnErr bool) (map[string]bool, error) {
p, err := cm.getConsentFiles()
if err != nil {
return nil, err
}

consentStates := make(map[string]bool)
for source, path := range p {
consent, err := readConsentFile(path)
if err != nil && !continueOnErr {
return nil, err
}
if err != nil {
continue
}
Sploder12 marked this conversation as resolved.
Show resolved Hide resolved

consentStates[source] = consent.ConsentState
}

return consentStates, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
true
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
true
Loading
Loading