diff --git a/README.md b/README.md index 70aa6b4..627a3d2 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,9 @@ A Go library to simplify the creation and management of application profiles nat This project was born out of [OpenTDF](https://github.com/opentdf/platform) and [otdfctl](https://github.com/opentdf/otdfctl). -The `profiles` implementation still contains aspects that are otdfctl-specific or OpenTDF platform specific and should be removed. - Further next steps: 1. update store abstraction to more of a typical key/value? -2. make the value stored for each profile more generic (`interface{}` or `map[string]interface{}`) -3. OS drivers so file system locations (and any other/future concerns) are properly set based on the OS -4. tests / CI -5. docs -6. example CLI +2. tests +3. docs +4. test OS platform directories work as desired diff --git a/errors.go b/errors.go index 51e8f2b..c244a4f 100644 --- a/errors.go +++ b/errors.go @@ -3,7 +3,6 @@ package profiles import "errors" var ( - ErrDeletingDefaultProfile = errors.New("error: cannot delete the default profile") ErrProfileNameConflict = errors.New("error: profile name already exists in storage") ErrMissingCurrentProfile = errors.New("error: current profile not set") ErrMissingDefaultProfile = errors.New("error: default profile not set") diff --git a/globalConfig.go b/internal/global/config.go similarity index 79% rename from globalConfig.go rename to internal/global/config.go index ddd4fe3..ddaa56d 100644 --- a/globalConfig.go +++ b/internal/global/config.go @@ -1,9 +1,25 @@ -package profiles +package global import ( + "encoding/json" + "github.com/jrschumacher/go-osprofiles/pkg/store" ) +// Define constants for the different storage drivers and store keys +const ( + PROFILE_DRIVER_KEYRING ProfileDriver = "keyring" + PROFILE_DRIVER_IN_MEMORY ProfileDriver = "in-memory" + PROFILE_DRIVER_FILE ProfileDriver = "file" + // Experimental: enables definition of custom storage driver + PROFILE_DRIVER_CUSTOM ProfileDriver = "custom" + PROFILE_DRIVER_DEFAULT = PROFILE_DRIVER_FILE + STORE_KEY_PROFILE = "profile" + STORE_KEY_GLOBAL = "global" +) + +type ProfileDriver string + // This variable is used to store the version of the profiles. Since the profiles structure might // change in the future, this variable is used to keep track of the version of the profiles and will // be used to determine how to handle migration of the profiles. @@ -40,7 +56,14 @@ func LoadGlobalConfig(configName string, newStore store.NewStoreInterface) (*Glo } if p.store.Exists() { - err := p.store.Get(&p.config) + data, err := p.store.Get() + if err != nil { + return nil, err + } + err = json.Unmarshal(data, &p.config) + if err != nil { + return nil, err + } // check the version of the profiles if p.config.ProfilesVersion != PROFILES_VERSION_LATEST { diff --git a/internal/global/errors.go b/internal/global/errors.go new file mode 100644 index 0000000..20f26bd --- /dev/null +++ b/internal/global/errors.go @@ -0,0 +1,5 @@ +package global + +import "errors" + +var ErrDeletingDefaultProfile = errors.New("error: cannot delete the default profile") diff --git a/pkg/platform/darwin.go b/pkg/platform/darwin.go new file mode 100644 index 0000000..4481d64 --- /dev/null +++ b/pkg/platform/darwin.go @@ -0,0 +1,35 @@ +package platform + +import "path/filepath" + +type PlatformDarwin struct { + username string + serviceNamespace string + userHomeDir string +} + +func NewPlatformDarwin(username, serviceNamespace, userHomeDir string) Platform { + return PlatformDarwin{username, serviceNamespace, userHomeDir} +} + +// TODO: validate these are correct + +// GetUsername returns the username for macOS. +func (p PlatformDarwin) GetUsername() string { + return p.username +} + +// GetUserHomeDir returns the user's home directory on macOS. +func (p PlatformDarwin) GetUserHomeDir() string { + return p.userHomeDir +} + +// GetDataDirectory returns the data directory for macOS. +func (p PlatformDarwin) GetDataDirectory() string { + return filepath.Join(p.userHomeDir, "Library", "Application Support") +} + +// GetConfigDirectory returns the config directory for macOS. +func (p PlatformDarwin) GetConfigDirectory() string { + return filepath.Join(p.userHomeDir, "Library", "Preferences") +} diff --git a/pkg/platform/errors.go b/pkg/platform/errors.go new file mode 100644 index 0000000..2b67698 --- /dev/null +++ b/pkg/platform/errors.go @@ -0,0 +1,5 @@ +package platform + +import "errors" + +var ErrGettingUserOS = errors.New("error getting current user from operating system") \ No newline at end of file diff --git a/pkg/platform/linux.go b/pkg/platform/linux.go new file mode 100644 index 0000000..e1c674d --- /dev/null +++ b/pkg/platform/linux.go @@ -0,0 +1,35 @@ +package platform + +import "path/filepath" + +type PlatformLinux struct { + username string + serviceNamespace string + userHomeDir string +} + +func NewPlatformLinux(username, serviceNamespace, userHomeDir string) PlatformLinux { + return PlatformLinux{username, serviceNamespace, userHomeDir} +} + +// TODO: validate these are correct + +// GetUsername returns the username for the Linux OS. +func (p PlatformLinux) GetUsername() string { + return p.username +} + +// GetUserHomeDir returns the user's home directory on the Linux OS. +func (p PlatformLinux) GetUserHomeDir() string { + return p.userHomeDir +} + +// GetDataDirectory returns the data directory for Linux. +func (p PlatformLinux) GetDataDirectory() string { + return filepath.Join(p.userHomeDir, ".local", "share") +} + +// GetConfigDirectory returns the config directory for Linux. +func (p PlatformLinux) GetConfigDirectory() string { + return filepath.Join(p.userHomeDir, ".config") +} diff --git a/pkg/platform/platform.go b/pkg/platform/platform.go new file mode 100644 index 0000000..296384c --- /dev/null +++ b/pkg/platform/platform.go @@ -0,0 +1,65 @@ +package platform + +import ( + "os" + "os/user" + "runtime" +) + +type Platform interface { + // Get the username as known to the operating system + GetUsername() string + // Get the user's home directory + GetUserHomeDir() string + // Get the data directory for the platform + GetDataDirectory() string + // Get the config directory for the platform + GetConfigDirectory() string +} + +// NewPlatform creates a new platform object based on the current operating system +func NewPlatform(serviceNamespace string) (Platform, error) { + username, userHomeDir, err := getCurrentUserOS() + if err != nil { + return nil, err + } + + switch runtime.GOOS { + case "linux": + return NewPlatformLinux(username, serviceNamespace, userHomeDir), nil + case "windows": + return NewPlatformWindows(username, serviceNamespace, userHomeDir), nil + case "darwin": + return NewPlatformDarwin(username, serviceNamespace, userHomeDir), nil + default: + return nil, ErrGettingUserOS + } +} + +// getCurrentUserOS gets the current username and home directory +func getCurrentUserOS() (string, string, error) { + usrHomeDir, err := os.UserHomeDir() + if err != nil { + return "", "", ErrGettingUserOS + } + var usr *user.User + // Check platform + if runtime.GOOS == "windows" { + // On Windows, use user.Current() if available, else fallback to environment variable + usr, err = user.Current() + if err != nil { + // TODO: test this on windows + usr = &user.User{Username: os.Getenv("USERNAME")} + if usr.Username == "" { + return "", "", ErrGettingUserOS + } + } + } else { + // On Unix-like systems (Linux, macOS), use user.Current() + usr, err = user.Current() + if err != nil { + return "", "", ErrGettingUserOS + } + } + return usr.Username, usrHomeDir, nil +} diff --git a/pkg/platform/windows.go b/pkg/platform/windows.go new file mode 100644 index 0000000..f0e6917 --- /dev/null +++ b/pkg/platform/windows.go @@ -0,0 +1,35 @@ +package platform + +import "path/filepath" + +type PlatformWindows struct { + username string + serviceNamespace string + userHomeDir string +} + +func NewPlatformWindows(username, serviceNamespace, userHomeDir string) PlatformWindows { + return PlatformWindows{username, serviceNamespace, userHomeDir} +} + +// TODO: validate these are correct + +// GetUsername returns the username for Windows. +func (p PlatformWindows) GetUsername() string { + return p.username +} + +// GetUserHomeDir returns the user's home directory on Windows. +func (p PlatformWindows) GetUserHomeDir() string { + return p.userHomeDir +} + +// GetDataDirectory returns the data directory for Windows. +func (p PlatformWindows) GetDataDirectory() string { + return filepath.Join(p.userHomeDir, "AppData", "Roaming") +} + +// GetConfigDirectory returns the config directory for Windows. +func (p PlatformWindows) GetConfigDirectory() string { + return filepath.Join(p.userHomeDir, "AppData", "Local") +} diff --git a/pkg/store/errors.go b/pkg/store/errors.go index a47ae58..2650d93 100644 --- a/pkg/store/errors.go +++ b/pkg/store/errors.go @@ -14,4 +14,6 @@ var ( ErrValueBadCharacters = errors.New("error: value contains invalid characters") ErrLengthExceeded = errors.New("error: length exceeded") + + ErrStoreDriverSetup = errors.New("error: store driver setup failed") ) diff --git a/pkg/store/store.go b/pkg/store/store.go index 2172346..d77b4dc 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -6,32 +6,41 @@ import ( "regexp" ) -// StoreInterface is an interface for a store of a single key and value under a namespace. -type NewStoreInterface func(namespace string, key string) (StoreInterface, error) +// DriverOpt is a variadic function to apply any driver-specific options, which +// apply any side effects/hooks necessary for the driver. +type DriverOpt func() error -// TODO: should we reconfigure this abstraction so we have a more traditional key-value store? +// StoreInterface is an interface for a store of a single key and value under a namespace. +// The key is unique within the namespace, and the stored value is a JSON-serialized struct. +// +// In a CLI 'example_cli' consuming this store to save user profiles, the namespace would be 'example_cli', +// and the key would be the specific CLI user's profile name. +type NewStoreInterface func(serviceNamespace, key string, driverOpt ...DriverOpt) (StoreInterface, error) // StoreInterface is a reusable interface that varied drivers can share to implement a store. +// TODO: should we reconfigure this abstraction so we have a more traditional key-value store? type StoreInterface interface { // Exists returns true if the value exists in the store. Exists() bool - // Get retrieves the entry from the store and unmarshals it into the provided value. - Get(value interface{}) error + // Get retrieves the entry bytes from the store. + Get() ([]byte, error) // Set marshals the provided value into JSON and stores it. Set(value interface{}) error // Delete removes the entry from the store. Delete() error - } +// NewCustomStore is a package global to init a custom store implementation. +var NewCustomStore NewStoreInterface + const maxFileNameLength = 255 // ValidateNamespaceKey ensures the namespace and key are valid and within length bounds. -func ValidateNamespaceKey(namespace, key string) error { +func ValidateNamespaceKey(serviceNamespace, key string) error { // Regular expression for allowed characters (alphanumerics, underscore, hyphen) validName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - if len(namespace) == 0 { + if len(serviceNamespace) == 0 { return errors.Join(ErrNamespaceInvalid, ErrValueEmpty) } @@ -39,18 +48,18 @@ func ValidateNamespaceKey(namespace, key string) error { return errors.Join(ErrKeyInvalid, ErrValueEmpty) } - if !validName.MatchString(namespace) { - return fmt.Errorf("%w, %w, namespace: %s", ErrNamespaceInvalid, ErrValueBadCharacters, namespace) + if !validName.MatchString(serviceNamespace) { + return fmt.Errorf("%w, %w, namespace: %s", ErrNamespaceInvalid, ErrValueBadCharacters, serviceNamespace) } if !validName.MatchString(key) { return fmt.Errorf("%w, %w, key: %s", ErrKeyInvalid, ErrValueBadCharacters, key) } // Ensure the filename is within length bounds when including a file extension - filename := fmt.Sprintf("%s_%s.ext", namespace, key) + filename := fmt.Sprintf("%s_%s.ext", serviceNamespace, key) if len(filename) > maxFileNameLength { return fmt.Errorf("%w, .ext exceeds maximum length (%d): %s", ErrLengthExceeded, maxFileNameLength, filename) } return nil -} \ No newline at end of file +} diff --git a/pkg/store/storeFileSystem.go b/pkg/store/storeFileSystem.go index fcfca48..b0a55a2 100644 --- a/pkg/store/storeFileSystem.go +++ b/pkg/store/storeFileSystem.go @@ -16,7 +16,7 @@ import ( "github.com/zalando/go-keyring" ) -type FileStore struct { +type fileStore struct { namespaceVersionURN string namespace string key string @@ -24,7 +24,7 @@ type FileStore struct { } // Metadata structure for unencrypted metadata about the encrypted file -type FileMetadata struct { +type fileMetadata struct { ProfileName string `json:"profile_name"` CreatedAt string `json:"created_at"` EncryptionAlg string `json:"encryption_alg"` @@ -38,22 +38,40 @@ const ( ownerPermissionsRWX = 0o700 ) +// Directory where profiles are stored when using the fileStore driver +var storeDirectory string + +// Assigns the store directory for the fileStore driver +func WithStoreDirectory(storeDir string) DriverOpt { + return func() error { + storeDirectory = storeDir + return nil + } +} + // TODO: should we use this throughout all stores and add it to the interface? // URN-based namespace template without UUID, using only profile name for uniqueness -func BuildNamespaceURN(configName, version string) string { - return fmt.Sprintf("urn:goosprofiles:%s:profile:%s", configName, version) // e.g., urn:goosprofiles:otdfctl:profile:v1: +// i.e. urn:goosprofiles::profile:: +func BuildNamespaceURN(serviceNamespace, version string) string { + return fmt.Sprintf("urn:goosprofiles:%s:profile:%s", serviceNamespace, version) } -// NewFileStore is the constructor function for FileStore, setting the file path based on executable directory or environment variable and hashed filename -var NewFileStore NewStoreInterface = func(namespace string, key string) (StoreInterface, error) { - if err := ValidateNamespaceKey(namespace, key); err != nil { +// NewFileStore is the constructor function for fileStore, setting the file path based on executable directory or environment variable and hashed filename +var NewFileStore NewStoreInterface = func(serviceNamespace, key string, driverOpts ...DriverOpt) (StoreInterface, error) { + if err := ValidateNamespaceKey(serviceNamespace, key); err != nil { return nil, err } - // Check for OTDFCTL_PROFILE_PATH environment variable - baseDir := os.Getenv("OTDFCTL_PROFILE_PATH") + // Apply any driver options + for _, opt := range driverOpts { + if err := opt(); err != nil { + return nil, errors.Join(ErrStoreDriverSetup, err) + } + } + + // Either storeDirectory is set by the WithStoreDirectory option or the "profiles" directory relative to the running executable + baseDir := storeDirectory if baseDir == "" { - // If environment variable is not set, use the "profiles" directory relative to the executable execPath, err := os.Executable() if err != nil { panic("unable to determine the executable path for profile storage") @@ -61,10 +79,12 @@ var NewFileStore NewStoreInterface = func(namespace string, key string) (StoreIn execDir := filepath.Dir(execPath) baseDir = filepath.Join(execDir, "profiles") } + // Ensure the base directory exists with owner-only access including execute if err := os.MkdirAll(baseDir, ownerPermissionsRWX); err != nil { panic(fmt.Sprintf("failed to create profiles directory %s: please check directory permissions", baseDir)) } + // Check for read/write permissions by creating and removing a temp file testFilePath := filepath.Join(baseDir, ".tmp_profile_rw_test") testFile, err := os.Create(testFilePath) @@ -75,44 +95,39 @@ var NewFileStore NewStoreInterface = func(namespace string, key string) (StoreIn if err := os.Remove(testFilePath); err != nil { panic(fmt.Sprintf("unable to delete temp file in profiles directory %s: please ensure delete permissions are granted", baseDir)) } - urn := BuildNamespaceURN(namespace, version1) - // Generate the filename hashed for uniqueness - // Note: other stores use the config.AppName, but want to rely on something more resilient like the namespace + + urn := BuildNamespaceURN(serviceNamespace, version1) fileName := fmt.Sprintf("%s_%s", urn, key) filePath := filepath.Join(baseDir, fileName+".enc") - return &FileStore{ + return &fileStore{ namespaceVersionURN: urn, - namespace: namespace, + namespace: serviceNamespace, key: key, filePath: filePath, }, nil } // Exists checks if the encrypted file exists -func (f *FileStore) Exists() bool { +func (f *fileStore) Exists() bool { _, err := os.Stat(f.filePath) return err == nil } // Get retrieves and decrypts data from the file -func (f *FileStore) Get(value interface{}) error { +func (f *fileStore) Get() ([]byte, error) { key, err := f.getEncryptionKey() if err != nil { - return err + return nil, err } encryptedData, err := os.ReadFile(f.filePath) if err != nil { - return err - } - data, err := decryptData(key, encryptedData) - if err != nil { - return err + return nil, err } - return json.NewDecoder(bytes.NewReader(data)).Decode(value) + return decryptData(key, encryptedData) } // Set encrypts and saves data to the file, also saving metadata -func (f *FileStore) Set(value interface{}) error { +func (f *fileStore) Set(value interface{}) error { key, err := f.getEncryptionKey() if err != nil { return err @@ -135,7 +150,7 @@ func (f *FileStore) Set(value interface{}) error { } // Delete removes the encrypted file and metadata file from disk -func (f *FileStore) Delete() error { +func (f *fileStore) Delete() error { if err := os.Remove(f.filePath); err != nil { return err } @@ -145,7 +160,7 @@ func (f *FileStore) Delete() error { } // getEncryptionKey retrieves the encryption key from the keyring or generates it if absent -func (f *FileStore) getEncryptionKey() ([]byte, error) { +func (f *fileStore) getEncryptionKey() ([]byte, error) { // Try retrieving the key as a string from the keyring keyStr, err := keyring.Get(f.namespaceVersionURN, f.key) if errors.Is(err, keyring.ErrNotFound) { @@ -208,8 +223,8 @@ func decryptData(key, encryptedData []byte) ([]byte, error) { } // SaveMetadata writes unencrypted metadata to a .nfo file -func (f *FileStore) SaveMetadata(profileName string) error { - metadata := FileMetadata{ +func (f *fileStore) SaveMetadata(profileName string) error { + metadata := fileMetadata{ ProfileName: profileName, CreatedAt: time.Now().Format(time.RFC3339), EncryptionAlg: "AES-256-GCM", @@ -224,13 +239,13 @@ func (f *FileStore) SaveMetadata(profileName string) error { } // LoadMetadata loads and parses metadata from a .nfo file -func (f *FileStore) LoadMetadata() (*FileMetadata, error) { +func (f *fileStore) LoadMetadata() (*fileMetadata, error) { metadataFilePath := strings.TrimSuffix(f.filePath, filepath.Ext(f.filePath)) + ".nfo" data, err := os.ReadFile(metadataFilePath) if err != nil { return nil, err } - var metadata FileMetadata + var metadata fileMetadata if err := json.Unmarshal(data, &metadata); err != nil { return nil, err } diff --git a/pkg/store/storeKeyring.go b/pkg/store/storeKeyring.go index 9863e0f..ebcf6f3 100644 --- a/pkg/store/storeKeyring.go +++ b/pkg/store/storeKeyring.go @@ -7,35 +7,35 @@ import ( "github.com/zalando/go-keyring" ) -type KeyringStore struct { +type keyringStore struct { namespace string key string } -var NewKeyringStore NewStoreInterface = func(namespace string, key string) (StoreInterface, error) { - if err := ValidateNamespaceKey(namespace, key); err != nil { +var NewKeyringStore NewStoreInterface = func(serviceNamespace, key string, _ ...DriverOpt) (StoreInterface, error) { + if err := ValidateNamespaceKey(serviceNamespace, key); err != nil { return nil, err } - return &KeyringStore{ - namespace: namespace, + return &keyringStore{ + namespace: serviceNamespace, key: key, }, nil } -func (k *KeyringStore) Exists() bool { +func (k *keyringStore) Exists() bool { s, err := keyring.Get(k.namespace, k.key) return err == nil && s != "" } -func (k *KeyringStore) Get(value interface{}) error { +func (k *keyringStore) Get() ([]byte, error) { s, err := keyring.Get(k.namespace, k.key) if err != nil { - return err + return nil, err } - return json.NewDecoder(bytes.NewReader([]byte(s))).Decode(value) + return []byte(s), err } -func (k *KeyringStore) Set(value interface{}) error { +func (k *keyringStore) Set(value interface{}) error { var b bytes.Buffer if err := json.NewEncoder(&b).Encode(value); err != nil { return err @@ -43,6 +43,6 @@ func (k *KeyringStore) Set(value interface{}) error { return keyring.Set(k.namespace, k.key, b.String()) } -func (k *KeyringStore) Delete() error { +func (k *keyringStore) Delete() error { return keyring.Delete(k.namespace, k.key) } diff --git a/pkg/store/storeMemory.go b/pkg/store/storeMemory.go index 03edc01..8437db1 100644 --- a/pkg/store/storeMemory.go +++ b/pkg/store/storeMemory.go @@ -1,11 +1,10 @@ package store import ( - "bytes" "encoding/json" ) -type MemoryStore struct { +type memoryStore struct { namespace string key string @@ -14,39 +13,36 @@ type MemoryStore struct { // NewMemoryStore creates a new in-memory store // JSON is used to serialize the data to ensure the interface is consistent with other store implementations -var NewMemoryStore NewStoreInterface = func(namespace string, key string) (StoreInterface, error) { - if err := ValidateNamespaceKey(namespace, key); err != nil { +var NewMemoryStore NewStoreInterface = func(serviceNamespace, key string, _ ...DriverOpt) (StoreInterface, error) { + if err := ValidateNamespaceKey(serviceNamespace, key); err != nil { return nil, err } + memory := make(map[string]interface{}) - return &MemoryStore{ - namespace: namespace, + return &memoryStore{ + namespace: serviceNamespace, key: key, memory: &memory, }, nil } -func (k *MemoryStore) Exists() bool { +func (k *memoryStore) Exists() bool { m := *k.memory _, ok := m[k.key] return ok } -func (k *MemoryStore) Get(value interface{}) error { +func (k *memoryStore) Get() ([]byte, error) { m := *k.memory v, ok := m[k.key] if !ok { - return nil + return nil, nil } - b, err := json.Marshal(v) - if err != nil { - return err - } - return json.NewDecoder(bytes.NewReader(b)).Decode(value) + return json.Marshal(v) } -func (k *MemoryStore) Set(value interface{}) error { +func (k *memoryStore) Set(value interface{}) error { b, err := json.Marshal(value) if err != nil { return err @@ -58,7 +54,7 @@ func (k *MemoryStore) Set(value interface{}) error { return nil } -func (k *MemoryStore) Delete() error { +func (k *memoryStore) Delete() error { m := *k.memory delete(m, k.key) // maybe write back to k.memory diff --git a/profile.go b/profile.go index ccab164..9c921dc 100644 --- a/profile.go +++ b/profile.go @@ -1,68 +1,74 @@ package profiles import ( - "github.com/jrschumacher/go-osprofiles/pkg/store" -) + "fmt" -// Define constants for the different storage drivers and store keys -const ( - PROFILE_DRIVER_KEYRING ProfileDriver = "keyring" - PROFILE_DRIVER_IN_MEMORY ProfileDriver = "in-memory" - PROFILE_DRIVER_FILE ProfileDriver = "file" - PROFILE_DRIVER_DEFAULT = PROFILE_DRIVER_FILE - STORE_KEY_PROFILE = "profile" - STORE_KEY_GLOBAL = "global" + "github.com/jrschumacher/go-osprofiles/internal/global" + "github.com/jrschumacher/go-osprofiles/pkg/store" ) type profileConfig struct { configName string - driver ProfileDriver + driver global.ProfileDriver + + driverOpts []store.DriverOpt } -type Profile struct { +// Profiler is the main interface for managing profiles +type Profiler struct { config profileConfig - globalStore *GlobalStore + globalStore *global.GlobalStore currentProfileStore *ProfileStore } type ( profileConfigVariadicFunc func(profileConfig) profileConfig - ProfileDriver string ) // Variadic functions to set different storage drivers func WithInMemoryStore() profileConfigVariadicFunc { return func(c profileConfig) profileConfig { - c.driver = PROFILE_DRIVER_IN_MEMORY + c.driver = global.PROFILE_DRIVER_IN_MEMORY return c } } func WithKeyringStore() profileConfigVariadicFunc { return func(c profileConfig) profileConfig { - c.driver = PROFILE_DRIVER_KEYRING + c.driver = global.PROFILE_DRIVER_KEYRING + return c + } +} + +func WithFileStore(storeDir string) profileConfigVariadicFunc { + return func(c profileConfig) profileConfig { + c.driver = global.PROFILE_DRIVER_FILE + c.driverOpts = append(c.driverOpts, store.WithStoreDirectory(storeDir)) return c } } -func WithFileStore() profileConfigVariadicFunc { +func WithCustomStore(newCustomStore store.NewStoreInterface) profileConfigVariadicFunc { return func(c profileConfig) profileConfig { - c.driver = PROFILE_DRIVER_FILE + c.driver = global.PROFILE_DRIVER_CUSTOM + store.NewCustomStore = newCustomStore return c } } // newStoreFactory returns a storage interface based on the configured driver -func newStoreFactory(driver ProfileDriver) store.NewStoreInterface { +func newStoreFactory(driver global.ProfileDriver) store.NewStoreInterface { switch driver { - case PROFILE_DRIVER_KEYRING: + case global.PROFILE_DRIVER_KEYRING: return store.NewKeyringStore - case PROFILE_DRIVER_IN_MEMORY: + case global.PROFILE_DRIVER_IN_MEMORY: return store.NewMemoryStore - case PROFILE_DRIVER_FILE: + case global.PROFILE_DRIVER_FILE: return store.NewFileStore + case global.PROFILE_DRIVER_CUSTOM: + return store.NewCustomStore default: return nil } @@ -70,15 +76,13 @@ func newStoreFactory(driver ProfileDriver) store.NewStoreInterface { // New creates a new Profile with the specified configuration options. // The configName is required and must be unique to the application. -func New(configName string, opts ...profileConfigVariadicFunc) (*Profile, error) { +func New(configName string, opts ...profileConfigVariadicFunc) (*Profiler, error) { var err error - if testProfile != nil { - return testProfile, nil - } - + // Apply configuration options config := profileConfig{ - driver: PROFILE_DRIVER_DEFAULT, + driver: global.PROFILE_DRIVER_DEFAULT, + configName: configName, } for _, opt := range opts { config = opt(config) @@ -90,12 +94,12 @@ func New(configName string, opts ...profileConfigVariadicFunc) (*Profile, error) return nil, ErrInvalidStoreDriver } - p := &Profile{ + p := &Profiler{ config: config, } // Load global configuration - p.globalStore, err = LoadGlobalConfig(configName, newStore) + p.globalStore, err = global.LoadGlobalConfig(configName, newStore) if err != nil { return nil, err } @@ -104,13 +108,18 @@ func New(configName string, opts ...profileConfigVariadicFunc) (*Profile, error) } // GetGlobalConfig returns the global configuration -func (p *Profile) GetGlobalConfig() *GlobalStore { +func GetGlobalConfig(p *Profiler) *global.GlobalStore { return p.globalStore } // AddProfile adds a new profile to the current configuration -func (p *Profile) AddProfile(profileName string, endpoint string, tlsNoVerify bool, setDefault bool) error { +func (p *Profiler) AddProfile(profile NamedProfile, setDefault bool) error { var err error + profileName := profile.GetName() + + if err := validateProfileName(profileName); err != nil { + return err + } // Check if the profile already exists if p.globalStore.ProfileExists(profileName) { @@ -118,7 +127,7 @@ func (p *Profile) AddProfile(profileName string, endpoint string, tlsNoVerify bo } // Create profile store and save - p.currentProfileStore, err = NewProfileStore(p.config.configName, newStoreFactory(p.config.driver), profileName, endpoint, tlsNoVerify) + p.currentProfileStore, err = NewProfileStore(p.config.configName, newStoreFactory(p.config.driver), profile) if err != nil { return err } @@ -139,7 +148,7 @@ func (p *Profile) AddProfile(profileName string, endpoint string, tlsNoVerify bo } // GetCurrentProfile returns the current stored profile -func (p *Profile) GetCurrentProfile() (*ProfileStore, error) { +func GetCurrentProfile(p *Profiler) (*ProfileStore, error) { if p.currentProfileStore == nil { return nil, ErrMissingCurrentProfile } @@ -147,43 +156,56 @@ func (p *Profile) GetCurrentProfile() (*ProfileStore, error) { } // GetProfile returns the profile store for the specified profile name -func (p *Profile) GetProfile(profileName string) (*ProfileStore, error) { +func GetProfile[T NamedProfile](p *Profiler, profileName string) (*ProfileStore, error) { if !p.globalStore.ProfileExists(profileName) { return nil, ErrMissingProfileName } - return LoadProfileStore(p.config.configName, newStoreFactory(p.config.driver), profileName) + return LoadProfileStore[T](p.config.configName, newStoreFactory(p.config.driver), profileName) } // ListProfiles returns a list of all profile names -func (p *Profile) ListProfiles() []string { +func ListProfiles(p *Profiler) []string { return p.globalStore.ListProfiles() } // UseProfile sets the current profile to the specified profile name -func (p *Profile) UseProfile(profileName string) (*ProfileStore, error) { +func UseProfile[T NamedProfile](p *Profiler, profileName string) (*ProfileStore, error) { var err error // If current profile is already set to this, return it - if p.currentProfileStore != nil && p.currentProfileStore.config.Name == profileName { + if p.currentProfileStore != nil && p.currentProfileStore.Profile.GetName() == profileName { return p.currentProfileStore, nil } // Set current profile - p.currentProfileStore, err = p.GetProfile(profileName) + p.currentProfileStore, err = GetProfile[T](p, profileName) return p.currentProfileStore, err } // UseDefaultProfile sets the current profile to the default profile -func (p *Profile) UseDefaultProfile() (*ProfileStore, error) { +func UseDefaultProfile[T NamedProfile](p *Profiler) (*ProfileStore, error) { defaultProfile := p.globalStore.GetDefaultProfile() if defaultProfile == "" { return nil, ErrMissingDefaultProfile } - return p.UseProfile(defaultProfile) + return UseProfile[T](p, defaultProfile) +} + +// UpdateProfile updates the current profile with new data +func UpdateCurrentProfile(p *Profiler, profile NamedProfile) error { + if p.currentProfileStore == nil { + return fmt.Errorf("error: store cannot be nil, %w", ErrInvalidStoreDriver) + } + if p.currentProfileStore.Profile == nil { + return fmt.Errorf("error: profile cannot be nil, %w", ErrMissingCurrentProfile) + } + // TODO: update the global store here? + p.currentProfileStore.Profile = profile + return p.currentProfileStore.Save() } // SetDefaultProfile sets the a specified profile to the default profile -func (p *Profile) SetDefaultProfile(profileName string) error { +func SetDefaultProfile(p *Profiler, profileName string) error { if !p.globalStore.ProfileExists(profileName) { return ErrMissingProfileName } @@ -191,14 +213,14 @@ func (p *Profile) SetDefaultProfile(profileName string) error { } // DeleteProfile removes a profile from storage -func (p *Profile) DeleteProfile(profileName string) error { +func DeleteProfile[T NamedProfile](p *Profiler, profileName string) error { // Check if the profile exists if !p.globalStore.ProfileExists(profileName) { return ErrMissingProfileName } // Retrieve the profile - profile, err := LoadProfileStore(p.config.configName, newStoreFactory(p.config.driver), profileName) + profile, err := LoadProfileStore[T](p.config.configName, newStoreFactory(p.config.driver), profileName) if err != nil { return err } diff --git a/profileAuthCreds.go b/profileAuthCreds.go deleted file mode 100644 index e59da01..0000000 --- a/profileAuthCreds.go +++ /dev/null @@ -1,30 +0,0 @@ -package profiles - -const ( - PROFILE_AUTH_TYPE_CLIENT_CREDENTIALS = "client-credentials" - PROFILE_AUTH_TYPE_ACCESS_TOKEN = "access-token" -) - -type AuthCredentials struct { - AuthType string `json:"authType"` - ClientId string `json:"clientId"` - // Used for client credentials - ClientSecret string `json:"clientSecret,omitempty"` - AccessToken AuthCredentialsAccessToken `json:"accessToken,omitempty"` -} - -type AuthCredentialsAccessToken struct { - PublicClientID string `json:"publicClientId"` - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - Expiration int64 `json:"expiration"` -} - -func (p *ProfileStore) GetAuthCredentials() AuthCredentials { - return p.config.AuthCredentials -} - -func (p *ProfileStore) SetAuthCredentials(authCredentials AuthCredentials) error { - p.config.AuthCredentials = authCredentials - return p.Save() -} diff --git a/profileConfig.go b/profileConfig.go index 0f75817..af8e6ca 100644 --- a/profileConfig.go +++ b/profileConfig.go @@ -1,53 +1,64 @@ package profiles import ( + "encoding/json" + + "github.com/jrschumacher/go-osprofiles/internal/global" "github.com/jrschumacher/go-osprofiles/pkg/store" ) type ProfileStore struct { + // Store is the specific initialized driver that satisfies the StoreInterface. store store.StoreInterface - - config ProfileConfig + // Profile is the struct that holds the profile data and satisfies the NamedProfile interface. + // Exported to allow write/read access to the profile data being stored. + Profile NamedProfile } -type ProfileConfig struct { - Name string `json:"profile"` - // TODO: map[string]interface{} - // TODO: interface{}? - Endpoint string `json:"endpoint"` - TlsNoVerify bool `json:"tlsNoVerify"` - AuthCredentials AuthCredentials `json:"authCredentials"` +// NamedProfile is the holder of a profile containing a name and all stored profile data. +// It is marshaled on Get and unmarshaled on Set, so an interface is used to allow +// for any struct to be stored. The struct satisfying the interface must have JSON tags +// for each stored field. +// +// Example: +// +// type MyProfile struct { +// Name string `json:"name"` +// Email string `json:"email"` +// } +// +// func (p *MyProfile) GetName() string { +// return p.Name +// } +type NamedProfile interface { + GetName() string } -// TODO: do we need both of these (New and Load both)? +func NewProfileStore(serviceNamespace string, newStore store.NewStoreInterface, profile NamedProfile) (*ProfileStore, error) { + profileName := profile.GetName() -func NewProfileStore(configName string, newStore store.NewStoreInterface, profileName string, endpoint string, tlsNoVerify bool) (*ProfileStore, error) { if err := validateProfileName(profileName); err != nil { return nil, err } - store, err := newStore(configName, getStoreKey(profileName)) + store, err := newStore(serviceNamespace, getStoreKey(profileName)) if err != nil { return nil, err } p := &ProfileStore{ - store: store, - config: ProfileConfig{ - Name: profileName, - Endpoint: endpoint, - TlsNoVerify: tlsNoVerify, - }, + store: store, + Profile: profile, } return p, nil } -func LoadProfileStore(configName string, newStore store.NewStoreInterface, profileName string) (*ProfileStore, error) { +func LoadProfileStore[T NamedProfile](serviceNamespace string, newStore store.NewStoreInterface, profileName string) (*ProfileStore, error) { if err := validateProfileName(profileName); err != nil { return nil, err } - store, err := newStore(configName, getStoreKey(profileName)) + store, err := newStore(serviceNamespace, getStoreKey(profileName)) if err != nil { return nil, err } @@ -55,48 +66,42 @@ func LoadProfileStore(configName string, newStore store.NewStoreInterface, profi p := &ProfileStore{ store: store, } - return p, p.Get() + _, err = GetStoredProfile[T](p) + if err != nil { + return nil, err + } + return p, nil } -func (p *ProfileStore) Get() error { - return p.store.Get(&p.config) +// Generic wrapper for working with specific types +func GetStoredProfile[T NamedProfile](store *ProfileStore) (T, error) { + var profile T + data, err := store.store.Get() + if err != nil { + return profile, err + } + err = json.Unmarshal(data, &profile) + store.Profile = profile + return profile, err } +// Save the current profile data to the store func (p *ProfileStore) Save() error { - return p.store.Set(p.config) + return p.store.Set(p.Profile) } +// Delete the current profile from the store func (p *ProfileStore) Delete() error { return p.store.Delete() } // Profile Name func (p *ProfileStore) GetProfileName() string { - return p.config.Name -} - -// Endpoint -func (p *ProfileStore) GetEndpoint() string { - return p.config.Endpoint -} - -func (p *ProfileStore) SetEndpoint(endpoint string) error { - p.config.Endpoint = endpoint - return p.Save() -} - -// TLS No Verify -func (p *ProfileStore) GetTLSNoVerify() bool { - return p.config.TlsNoVerify -} - -func (p *ProfileStore) SetTLSNoVerify(tlsNoVerify bool) error { - p.config.TlsNoVerify = tlsNoVerify - return p.Save() + return p.Profile.GetName() } // utility functions func getStoreKey(n string) string { - return STORE_KEY_PROFILE + "-" + n + return global.STORE_KEY_PROFILE + "-" + n } diff --git a/test.go b/test.go deleted file mode 100644 index 81e6e38..0000000 --- a/test.go +++ /dev/null @@ -1,82 +0,0 @@ -package profiles - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - - "github.com/zalando/go-keyring" -) - -const testModeMsg = ` -******************** -RUNNING IN TEST MODE - -test config: %s -******************** - -` - -var ( - testProfile *Profile - testCfg = os.Getenv("CLI_TEST_PROFILE") - - // TestMode is a flag to enable test mode at build time (ldflags) - TestMode = "false" -) - -type mockGlobalConfig struct { - testKey string -} - -type testConfig struct { - // global config is used to get the store in a bad state - GlobalConfig mockGlobalConfig `json:"globalConfig,omitempty"` - - // set the default profile - DefaultProfile string `json:"defaultProfile,omitempty"` - - // profiles to add - Profiles []ProfileConfig `json:"profiles,omitempty"` -} - -func init() { - // If running in test mode, use the mock keyring - //nolint:nestif,forbidigo // test mode mocking so nested blocks and format directive make sense - if TestMode == "true" { - fmt.Printf(testModeMsg, testCfg) - - keyring.MockInit() - - // configure the keyring based on the test config - // unmarsal the test config - if testCfg != "" { - var err error - var cfg testConfig - //nolint:musttag // test config is annotated and this is a linter issue? - if err := json.NewDecoder(bytes.NewReader([]byte(testCfg))).Decode(&cfg); err != nil { - panic(err) - } - - testProfile, err = New("testConfig") - if err != nil { - panic(err) - } - - for _, p := range cfg.Profiles { - err := testProfile.AddProfile(p.Name, p.Endpoint, p.TlsNoVerify, cfg.DefaultProfile == p.Name) - if err != nil { - panic(err) - } - } - - // set default - if cfg.DefaultProfile != "" { - if err := testProfile.SetDefaultProfile(cfg.DefaultProfile); err != nil { - panic(err) - } - } - } - } -} diff --git a/util.go b/util.go index e1ec208..0467de6 100644 --- a/util.go +++ b/util.go @@ -2,6 +2,7 @@ package profiles import ( "errors" + "fmt" "regexp" ) @@ -10,7 +11,7 @@ var profileNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-_]?[a-z0-9])*$`) func validateProfileName(n string) error { // check profile name is valid [a-zA-Z0-9_-] if n == "" { - return errors.New("profile name is required") + return fmt.Errorf("%w, profile name: ''", ErrMissingProfileName) } // check profile name is valid [a-zA-Z0-9_-] if !profileNameRegex.MatchString(n) {