Unistore: Add datastore (#107137)
* Add datastore * too many slashes * lint * pr comments * extract ParseKey
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
dataSection = "unified/data"
|
||||
)
|
||||
|
||||
// dataStore is a data store that uses a KV store to store data.
|
||||
type dataStore struct {
|
||||
kv KV
|
||||
}
|
||||
|
||||
func newDataStore(kv KV) *dataStore {
|
||||
return &dataStore{
|
||||
kv: kv,
|
||||
}
|
||||
}
|
||||
|
||||
type DataObj struct {
|
||||
Key DataKey
|
||||
Value io.ReadCloser
|
||||
}
|
||||
|
||||
type DataKey struct {
|
||||
Namespace string
|
||||
Group string
|
||||
Resource string
|
||||
Name string
|
||||
ResourceVersion int64
|
||||
Action DataAction
|
||||
}
|
||||
|
||||
var (
|
||||
// validNameRegex validates that a name contains only lowercase alphanumeric characters, '-' or '.'
|
||||
// and starts and ends with an alphanumeric character
|
||||
validNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`)
|
||||
)
|
||||
|
||||
func (k DataKey) String() string {
|
||||
return fmt.Sprintf("%s/%s/%s/%s/%d~%s", k.Namespace, k.Group, k.Resource, k.Name, k.ResourceVersion, k.Action)
|
||||
}
|
||||
|
||||
func (k DataKey) Validate() error {
|
||||
if k.Namespace == "" {
|
||||
if k.Group != "" || k.Resource != "" || k.Name != "" {
|
||||
return fmt.Errorf("namespace is required when group, resource, or name are provided")
|
||||
}
|
||||
return fmt.Errorf("namespace cannot be empty")
|
||||
}
|
||||
if k.Group == "" {
|
||||
if k.Resource != "" || k.Name != "" {
|
||||
return fmt.Errorf("group is required when resource or name are provided")
|
||||
}
|
||||
return fmt.Errorf("group cannot be empty")
|
||||
}
|
||||
if k.Resource == "" {
|
||||
if k.Name != "" {
|
||||
return fmt.Errorf("resource is required when name is provided")
|
||||
}
|
||||
return fmt.Errorf("resource cannot be empty")
|
||||
}
|
||||
if k.Name == "" {
|
||||
return fmt.Errorf("name cannot be empty")
|
||||
}
|
||||
if k.Action == "" {
|
||||
return fmt.Errorf("action cannot be empty")
|
||||
}
|
||||
|
||||
// Validate each field against the naming rules
|
||||
if !validNameRegex.MatchString(k.Namespace) {
|
||||
return fmt.Errorf("namespace '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Namespace)
|
||||
}
|
||||
if !validNameRegex.MatchString(k.Group) {
|
||||
return fmt.Errorf("group '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Group)
|
||||
}
|
||||
if !validNameRegex.MatchString(k.Resource) {
|
||||
return fmt.Errorf("resource '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Resource)
|
||||
}
|
||||
if !validNameRegex.MatchString(k.Name) {
|
||||
return fmt.Errorf("name '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Name)
|
||||
}
|
||||
|
||||
switch k.Action {
|
||||
case DataActionCreated, DataActionUpdated, DataActionDeleted:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("action '%s' is invalid: must be one of 'created', 'updated', or 'deleted'", k.Action)
|
||||
}
|
||||
}
|
||||
|
||||
type ListRequestKey struct {
|
||||
Namespace string
|
||||
Group string
|
||||
Resource string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (k ListRequestKey) Validate() error {
|
||||
// Check hierarchical validation - if a field is empty, more specific fields should also be empty
|
||||
if k.Namespace == "" {
|
||||
if k.Group != "" || k.Resource != "" || k.Name != "" {
|
||||
return fmt.Errorf("namespace is required when group, resource, or name are provided")
|
||||
}
|
||||
return nil // Empty namespace is allowed for ListRequestKey
|
||||
}
|
||||
if k.Group == "" {
|
||||
if k.Resource != "" || k.Name != "" {
|
||||
return fmt.Errorf("group is required when resource or name are provided")
|
||||
}
|
||||
// Only validate namespace if it's provided
|
||||
if !validNameRegex.MatchString(k.Namespace) {
|
||||
return fmt.Errorf("namespace '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Namespace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if k.Resource == "" {
|
||||
if k.Name != "" {
|
||||
return fmt.Errorf("resource is required when name is provided")
|
||||
}
|
||||
// Validate namespace and group if they're provided
|
||||
if !validNameRegex.MatchString(k.Namespace) {
|
||||
return fmt.Errorf("namespace '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Namespace)
|
||||
}
|
||||
if !validNameRegex.MatchString(k.Group) {
|
||||
return fmt.Errorf("group '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Group)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All fields are provided, validate each one
|
||||
if !validNameRegex.MatchString(k.Namespace) {
|
||||
return fmt.Errorf("namespace '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Namespace)
|
||||
}
|
||||
if !validNameRegex.MatchString(k.Group) {
|
||||
return fmt.Errorf("group '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Group)
|
||||
}
|
||||
if !validNameRegex.MatchString(k.Resource) {
|
||||
return fmt.Errorf("resource '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Resource)
|
||||
}
|
||||
if k.Name != "" && !validNameRegex.MatchString(k.Name) {
|
||||
return fmt.Errorf("name '%s' is invalid: must contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character", k.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k ListRequestKey) Prefix() string {
|
||||
if k.Namespace == "" {
|
||||
return ""
|
||||
}
|
||||
if k.Group == "" {
|
||||
return fmt.Sprintf("%s/", k.Namespace)
|
||||
}
|
||||
if k.Resource == "" {
|
||||
return fmt.Sprintf("%s/%s/", k.Namespace, k.Group)
|
||||
}
|
||||
if k.Name == "" {
|
||||
return fmt.Sprintf("%s/%s/%s/", k.Namespace, k.Group, k.Resource)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s/%s/", k.Namespace, k.Group, k.Resource, k.Name)
|
||||
}
|
||||
|
||||
type DataAction string
|
||||
|
||||
const (
|
||||
DataActionCreated DataAction = "created"
|
||||
DataActionUpdated DataAction = "updated"
|
||||
DataActionDeleted DataAction = "deleted"
|
||||
)
|
||||
|
||||
// Keys returns all keys for a given key by iterating through the KV store
|
||||
func (d *dataStore) Keys(ctx context.Context, key ListRequestKey) iter.Seq2[DataKey, error] {
|
||||
if err := key.Validate(); err != nil {
|
||||
return func(yield func(DataKey, error) bool) {
|
||||
yield(DataKey{}, err)
|
||||
}
|
||||
}
|
||||
|
||||
prefix := key.Prefix()
|
||||
return func(yield func(DataKey, error) bool) {
|
||||
for k, err := range d.kv.Keys(ctx, dataSection, ListOptions{
|
||||
StartKey: prefix,
|
||||
EndKey: PrefixRangeEnd(prefix),
|
||||
}) {
|
||||
if err != nil {
|
||||
yield(DataKey{}, err)
|
||||
return
|
||||
}
|
||||
key, err := ParseKey(k)
|
||||
if err != nil {
|
||||
yield(DataKey{}, err)
|
||||
return
|
||||
}
|
||||
if !yield(key, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dataStore) Get(ctx context.Context, key DataKey) (io.ReadCloser, error) {
|
||||
if err := key.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid data key: %w", err)
|
||||
}
|
||||
|
||||
obj, err := d.kv.Get(ctx, dataSection, key.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj.Value, nil
|
||||
}
|
||||
|
||||
func (d *dataStore) Save(ctx context.Context, key DataKey, value io.Reader) error {
|
||||
if err := key.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid data key: %w", err)
|
||||
}
|
||||
|
||||
return d.kv.Save(ctx, dataSection, key.String(), value)
|
||||
}
|
||||
|
||||
func (d *dataStore) Delete(ctx context.Context, key DataKey) error {
|
||||
if err := key.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid data key: %w", err)
|
||||
}
|
||||
|
||||
return d.kv.Delete(ctx, dataSection, key.String())
|
||||
}
|
||||
|
||||
// ParseKey parses a string key into a DataKey struct
|
||||
func ParseKey(key string) (DataKey, error) {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) != 5 {
|
||||
return DataKey{}, fmt.Errorf("invalid key: %s", key)
|
||||
}
|
||||
uidActionParts := strings.Split(parts[4], "~")
|
||||
if len(uidActionParts) != 2 {
|
||||
return DataKey{}, fmt.Errorf("invalid key: %s", key)
|
||||
}
|
||||
rv, err := strconv.ParseInt(uidActionParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return DataKey{}, fmt.Errorf("invalid resource version: %s", uidActionParts[0])
|
||||
}
|
||||
return DataKey{
|
||||
Namespace: parts[0],
|
||||
Group: parts[1],
|
||||
Resource: parts[2],
|
||||
Name: parts[3],
|
||||
ResourceVersion: rv,
|
||||
Action: DataAction(uidActionParts[1]),
|
||||
}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,15 @@ func setupTestBadgerDB(t *testing.T) *badger.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func setupTestKV(t *testing.T) KV {
|
||||
db := setupTestBadgerDB(t)
|
||||
t.Cleanup(func() {
|
||||
err := db.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
return NewBadgerKV(db)
|
||||
}
|
||||
|
||||
func TestBadgerKV_Get(t *testing.T) {
|
||||
db := setupTestBadgerDB(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user