Skip to content

Commit

Permalink
[SERV-1205] Add profiles (#45)
Browse files Browse the repository at this point in the history
* Add profiles/profile structs
  • Loading branch information
ksclarke authored Jan 21, 2025
1 parent 249efab commit 170a6df
Show file tree
Hide file tree
Showing 2 changed files with 323 additions and 0 deletions.
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()
}

0 comments on commit 170a6df

Please sign in to comment.