Skip to content

Commit

Permalink
Merge pull request #3 from ubuntu/UDENG-5705-Ubuntu-Insights-Consent-…
Browse files Browse the repository at this point in the history
…Manager

feat(internal/consent): UDENG-5705 ubuntu insights consent manager
  • Loading branch information
hk21702 authored Jan 21, 2025
2 parents 99bb96b + 5ad0e21 commit 6498f00
Show file tree
Hide file tree
Showing 35 changed files with 494 additions and 3 deletions.
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},

// 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
}

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

0 comments on commit 6498f00

Please sign in to comment.