Unistore: Add datastore (#107137)

* Add datastore

* too many slashes

* lint

* pr comments

* extract ParseKey
This commit is contained in:
Georges Chaudy
2025-06-26 10:36:31 +02:00
committed by GitHub
parent 3d543a336f
commit 2297417374
3 changed files with 1623 additions and 0 deletions
+260
View File
@@ -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
+9
View File
@@ -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)