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

[SERV-1205] Add profiles #45

Merged
merged 13 commits into from
Jan 21, 2025
223 changes: 223 additions & 0 deletions profiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// This code provides utilities for creating and working with validation profiles.
//
// Example usage:
//
// profiles := NewProfiles()
// if profile, err := NewProfile("example", []string{"Validation1", "Validation2"}); err != nil {
// require.NoError(t, err)
// } else {
// err = profiles.SetProfile(profile)
// require.NoError(t, err)
//
// fmt.Println(profiles.GetProfile("example").GetName())
// }
//
// snapshot := profiles.Snapshot()
// if jsonData, err := json.Marshal(snapshot); err != nil {
// require.NoError(t, err)
// } else {
// fmt.Println(string(jsonData))
// }
package main

import (
"fmt"
"sort"
"sync"
"time"
)

// Profile is a single thread-safe validation profile.
type Profile struct {
mutex sync.RWMutex
name string
lastUpdate time.Time
validations []string
}

// ProfileSnapshot is a temporary struct used for marshaling to JSON.
type ProfileSnapshot struct {
Name string `json:"name"`
LastUpdate time.Time `json:"lastUpdate"`
Validations []string `json:"validations"`
}

// Profiles contains a thread-safe mapping of validation Profile(s).
//
// We don't marshal this to JSON, but use a ProfilesSnapshot for that.
type Profiles struct {
mutex sync.RWMutex
profile map[string]*Profile
lastUpdate time.Time
}

// ProfilesSnapshot is a temporary struct used for marshaling to JSON.
type ProfilesSnapshot struct {
Profile map[string]ProfileSnapshot `json:"profiles"`
LastUpdate time.Time `json:"lastUpdate"`
}

// NewProfile is a constructor function to initialize a new Profile.
func NewProfile(name string, validations []string) (*Profile, error) {
if name == "" {
return nil, fmt.Errorf("profile name cannot be empty")
}

return &Profile{
name: name,
lastUpdate: time.Now(),
validations: append([]string(nil), validations...),
}, nil
}

// NewProfiles is a constructor function to initialize a new Profiles.
func NewProfiles() *Profiles {
return &Profiles{
profile: make(map[string]*Profile),
}
}

// Count the number of Profile(s) in this Profiles instance.
func (profiles *Profiles) Count() int {
profiles.mutex.RLock()
defer profiles.mutex.RUnlock()
return len(profiles.profile)
}

// GetName gets the name of the current Profile.
func (profile *Profile) GetName() string {
profile.mutex.RLock()
defer profile.mutex.RUnlock()
return profile.name
}

// GetLastUpdate gets the last update of the current Profile.
func (profile *Profile) GetLastUpdate() time.Time {
profile.mutex.RLock()
defer profile.mutex.RUnlock()
return profile.lastUpdate
}

// GetValidations gets the validation names of the current Profile.
func (profile *Profile) GetValidations() []string {
profile.mutex.RLock()
defer profile.mutex.RUnlock()
return append([]string(nil), profile.validations...)
}

// AddValidation adds a new validation name to the current Profile.
func (profile *Profile) AddValidation(validation string) {
profile.mutex.Lock()
defer profile.mutex.Unlock()

for _, v := range profile.validations {
if v == validation {
return // Skip duplicates
}
}

profile.validations = append(profile.validations, validation)
}

// GetProfile gets the Profile with the supplied name.
func (profiles *Profiles) GetProfile(name string) *Profile {
profiles.mutex.RLock()
defer profiles.mutex.RUnlock()

profile, exists := profiles.profile[name]
if !exists {
return nil
}

return profile
}

// SetName a new Profile name.
func (profile *Profile) SetName(name string) {
profile.mutex.Lock()
defer profile.mutex.Unlock()

profile.lastUpdate = time.Now()
profile.name = name
}

// SetValidations a Profile validations.
func (profile *Profile) SetValidations(validations []string) {
profile.mutex.Lock()
defer profile.mutex.Unlock()

profile.lastUpdate = time.Now()
uniqueValidations := make(map[string]struct{})
for _, v := range validations {
uniqueValidations[v] = struct{}{}
}

profile.validations = make([]string, 0, len(uniqueValidations))
for v := range uniqueValidations {
profile.validations = append(profile.validations, v)
}

// Sort for consistency
sort.Strings(profile.validations)
}

// SetProfile sets a new Profile in Profiles.
func (profiles *Profiles) SetProfile(profile *Profile) error {
if profile == nil {
return fmt.Errorf("cannot set a nil profile")
}

if profile.GetName() == "" {
return fmt.Errorf("cannot set a profile with an empty name")
}

profiles.mutex.Lock()
defer profiles.mutex.Unlock()

if profiles.profile == nil {
profiles.profile = make(map[string]*Profile)
}

profiles.lastUpdate = time.Now()
profiles.profile[profile.GetName()] = profile

return nil
}

// Snapshot provides a copy of Profile to marshal to JSON.
func (profile *Profile) Snapshot() ProfileSnapshot {
profile.mutex.RLock()
defer profile.mutex.RUnlock()

// Populate the temporary struct with current values
return ProfileSnapshot{
Name: profile.name,
LastUpdate: profile.lastUpdate,
Validations: append([]string(nil), profile.validations...),
}
}

// Snapshot provides a copy of Profiles to marshal to JSON.
func (profiles *Profiles) Snapshot() ProfilesSnapshot {
profiles.mutex.RLock()
defer profiles.mutex.RUnlock()

if profiles.profile == nil {
return ProfilesSnapshot{
Profile: make(map[string]ProfileSnapshot),
LastUpdate: time.Now(),
}
}

// Populate the temporary struct with current values
snapshot := ProfilesSnapshot{
Profile: make(map[string]ProfileSnapshot),
LastUpdate: profiles.lastUpdate,
}

for name, profile := range profiles.profile {
snapshot.Profile[name] = profile.Snapshot()
}

return snapshot
}
100 changes: 100 additions & 0 deletions profiles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//go:build unit

package main

import (
"bytes"
"encoding/json"
"fmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"log"
"os"
"strings"
"testing"
)

// TestProfiles tests basic Get/Set functionality.
func TestProfiles(t *testing.T) {
profiles := NewProfiles()
defaultProfile, err := NewProfile("default", []string{"SpaceCheck", "ARKFormat", "EOLCheck"})
require.NoError(t, err)
otherProfile, err := NewProfile("other", []string{"SpaceCheck", "ARKFormat"})
require.NoError(t, err)

err = profiles.SetProfile(defaultProfile)
require.NoError(t, err)
err = profiles.SetProfile(otherProfile)
require.NoError(t, err)

assert.Equal(t, 2, profiles.Count())
assert.Equal(t, profiles.GetProfile("default").GetName(), "default")
assert.Equal(t, profiles.GetProfile("other").GetName(), "other")
assert.Equal(t, len(profiles.GetProfile("default").GetValidations()), 3)
assert.Equal(t, len(profiles.GetProfile("other").GetValidations()), 2)
assert.False(t, profiles.GetProfile("default").GetLastUpdate().IsZero())
assert.False(t, profiles.GetProfile("other").GetLastUpdate().IsZero())
}

// TestSnapshot tests creating a bare-bones Snapshot through marshaling it to JSON.
func TestSnapshot(t *testing.T) {
profiles := NewProfiles()
snapshot := profiles.Snapshot()

jsonData, err := json.Marshal(snapshot)
if err != nil {
log.Fatalf("Error marshaling to JSON: %v", err)
}

// Confirm Snapshot is okay with no profiles set (i.e., we've created the slice)
assert.Equal(t, "{\"profiles\":{},\"lastUpdate\":\"0001-01-01T00:00:00Z\"}", string(jsonData))
}

// TestExampleCode tests the code that's used in profiles.go's inline docs.
func TestExampleCode(t *testing.T) {
// The function that's captured contains the example code used in the docs
output := captureOutput(t, func() {
profiles := NewProfiles()
if profile, err := NewProfile("example", []string{"Validation1", "Validation2"}); err == nil {
err = profiles.SetProfile(profile)
require.NoError(t, err)

fmt.Println(profiles.GetProfile("example").GetName())
} else {
require.NoError(t, err)
}

snapshot := profiles.Snapshot()
if jsonData, err := json.Marshal(snapshot); err == nil {
fmt.Println(string(jsonData))
} else {
require.NoError(t, err)
}
})

// A simple test that gets around lastUpdate being different
assert.True(t, strings.HasPrefix(output, "example"))
}

// captureOutput captures StdOut and allows running assertions on it
func captureOutput(t *testing.T, f func()) string {
var buf bytes.Buffer

originalStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

// Call the function we've passed in
f()

if err := w.Close(); err != nil {
t.Fatalf("Failed to close pipe writer: %v", err)
}

if _, err := buf.ReadFrom(r); err != nil {
t.Fatalf("Failed to read from pipe reader: %v", err)
}

os.Stdout = originalStdout
return buf.String()
}
Loading