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

Property service for saving properties to database #4248

Merged
merged 1 commit into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions internal/entities/properties/service/mock/service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

288 changes: 288 additions & 0 deletions internal/entities/properties/service/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package service provides a service to interact with properties of an entity
package service

import (
"context"
"database/sql"
"errors"
"time"

"github.com/google/uuid"

"github.com/stacklok/minder/internal/db"
"github.com/stacklok/minder/internal/engine/entities"
"github.com/stacklok/minder/internal/entities/properties"
minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
v1 "github.com/stacklok/minder/pkg/providers/v1"
)

//go:generate go run go.uber.org/mock/mockgen -package mock_$GOPACKAGE -destination=./mock/$GOFILE -source=./$GOFILE

const (
// propertiesCacheTimeout is the default timeout for the cache of properties
propertiesCacheTimeout = time.Duration(60) * time.Second
// bypassCacheTimeout is a special value to bypass the cache timeout
// it is not exported from the package and should only be used for testing
bypassCacheTimeout = time.Duration(-1)
)

// PropertiesService is the interface for the properties service
type PropertiesService interface {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first I wanted to have just the Retrieve methods that would save the entity as well when saving the properties. But - I don't know if we can generalize saving the entity, especially while we are still in migrating mode and are saving the entity with a defined UUID that we take from the older tables. So at least for now I split the methods into separate retrieve and save where the idea is that the caller would retrieve properties, save the entity and then save the properties.

// RetrieveAllProperties fetches all properties for the given entity
RetrieveAllProperties(
ctx context.Context, provider v1.Provider, projectId uuid.UUID,
lookupProperties *properties.Properties, entType minderv1.Entity,
) (*properties.Properties, error)
// RetrieveProperty fetches a single property for the given entity
RetrieveProperty(
ctx context.Context, provider v1.Provider, projectId uuid.UUID,
lookupProperties *properties.Properties, entType minderv1.Entity, key string,
) (*properties.Property, error)
// SaveAllProperties saves all properties for the given entity
SaveAllProperties(ctx context.Context, entityID uuid.UUID, props *properties.Properties) error
// SaveProperty saves a single property for the given entity
SaveProperty(ctx context.Context, entityID uuid.UUID, key string, prop *properties.Property) error
}

type propertiesServiceOption func(*propertiesService)

type propertiesService struct {
store db.Store
entityTimeout time.Duration
}

// WithEntityTimeout sets the timeout for the cache of properties
func WithEntityTimeout(timeout time.Duration) propertiesServiceOption {
return func(ps *propertiesService) {
ps.entityTimeout = timeout
}
}

// NewPropertiesService creates a new properties service
func NewPropertiesService(
store db.Store,
opts ...propertiesServiceOption,
) PropertiesService {
ps := &propertiesService{
store: store,
entityTimeout: propertiesCacheTimeout,
}

for _, opt := range opts {
opt(ps)
}

return ps
}

// RetrieveAllProperties fetches a single property for the given entity
func (ps *propertiesService) RetrieveAllProperties(
ctx context.Context, provider v1.Provider, projectId uuid.UUID,
lookupProperties *properties.Properties, entType minderv1.Entity,
) (*properties.Properties, error) {
// fetch the entity first. If there's no entity, there's no properties, go straight to provider
entID, err := ps.getEntityIdByProperties(ctx, projectId, lookupProperties, entType)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}

var dbProps []db.Property
if entID != uuid.Nil {
// fetch properties from db
dbProps, err = ps.store.GetAllPropertiesForEntity(ctx, entID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
}

// if exists and not expired, turn into our model
if len(dbProps) > 0 && ps.areDatabasePropertiesValid(dbProps) {
// TODO: instead of a hard error, should we just re-fetch from provider?
return dbPropsToModel(dbProps)
}

// if not, fetch from provider
props, err := provider.FetchAllProperties(ctx, lookupProperties, entType)
if err != nil {
return nil, err
}

return props, nil
}

// RetrieveProperty fetches a single property for the given entity
func (ps *propertiesService) RetrieveProperty(
ctx context.Context, provider v1.Provider, projectId uuid.UUID,
lookupProperties *properties.Properties, entType minderv1.Entity, key string,
) (*properties.Property, error) {
// fetch the entity first. If there's no entity, there's no properties, go straight to provider
entID, err := ps.getEntityIdByProperties(ctx, projectId, lookupProperties, entType)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}

// fetch properties from db
var dbProp db.Property
if entID != uuid.Nil {
dbProp, err = ps.store.GetProperty(ctx, db.GetPropertyParams{
EntityID: entID,
Key: key,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
}

// if exists, turn into our model
if ps.isDatabasePropertyValid(dbProp) {
return dbPropToModel(dbProp)
}

// if not, fetch from provider
prop, err := provider.FetchProperty(ctx, lookupProperties, entType, key)
if err != nil {
return nil, err
}

return prop, nil
}

func (ps *propertiesService) getEntityIdByProperties(
ctx context.Context, projectId uuid.UUID,
props *properties.Properties, entType minderv1.Entity,
) (uuid.UUID, error) {
// TODO: Add more ways to look up a property, e.g. by the upstream ID
name := props.GetProperty(properties.PropertyName)
if name != nil {
return ps.getEntityIdByName(ctx, projectId, name.GetString(), entType)
}

// returning nil ID and nil error would make us just go to the provider. Slow, but we'd continue.
return uuid.Nil, nil
}

func (ps *propertiesService) getEntityIdByName(
ctx context.Context, projectId uuid.UUID,
name string, entType minderv1.Entity,
) (uuid.UUID, error) {
ent, err := ps.store.GetEntityByName(ctx, db.GetEntityByNameParams{
ProjectID: projectId,
Name: name,
EntityType: entities.EntityTypeToDB(entType),
})
if err != nil {
return uuid.Nil, err
}

return ent.ID, nil
}

func (ps *propertiesService) SaveAllProperties(
ctx context.Context, entityID uuid.UUID, props *properties.Properties,
) error {
return ps.store.WithTransactionErr(func(qtx db.ExtendQuerier) error {
return replaceProperties(ctx, qtx, entityID, props)
})
}

func replaceProperties(ctx context.Context, txq db.ExtendQuerier, entityID uuid.UUID, props *properties.Properties) error {
err := txq.DeleteAllPropertiesForEntity(ctx, entityID)
if err != nil {
return err
}

for key, prop := range props.Iterate() {
_, err := txq.UpsertPropertyValueV1(ctx, db.UpsertPropertyValueV1Params{
EntityID: entityID,
Key: key,
Value: prop.RawValue(),
})
if err != nil {
return err
}
}

return nil
}

func (ps *propertiesService) SaveProperty(ctx context.Context, entityID uuid.UUID, key string, prop *properties.Property) error {
return ps.store.WithTransactionErr(func(qtx db.ExtendQuerier) error {
return upsertProperty(ctx, qtx, entityID, key, prop)
})
}

func upsertProperty(ctx context.Context, txq db.ExtendQuerier, entityID uuid.UUID, key string, prop *properties.Property) error {
if prop == nil {
return txq.DeleteProperty(ctx, db.DeletePropertyParams{
EntityID: entityID,
Key: key,
})
}

_, err := txq.UpsertPropertyValueV1(ctx, db.UpsertPropertyValueV1Params{
EntityID: entityID,
Key: key,
Value: prop.RawValue(),
})
return err
}

func (ps *propertiesService) areDatabasePropertiesValid(dbProps []db.Property) bool {
// if the all the properties are to be valid, neither must be older than
// the cache timeout
for _, prop := range dbProps {
if !ps.isDatabasePropertyValid(prop) {
return false
}
}
return true
}

func (ps *propertiesService) isDatabasePropertyValid(dbProp db.Property) bool {
if ps.entityTimeout == bypassCacheTimeout {
// this is mostly for testing
return false
}
return time.Since(dbProp.UpdatedAt) < ps.entityTimeout
}

func dbPropsToModel(dbProps []db.Property) (*properties.Properties, error) {
propMap := make(map[string]any)

// TODO: should we change the property API to include a Set
// and rather move the construction from a map to a separate method?
// this double iteration is not ideal
for _, prop := range dbProps {
anyVal, err := db.PropValueFromDbV1(prop.Value)
if err != nil {
return nil, err
}
propMap[prop.Key] = anyVal
}

return properties.NewProperties(propMap)
}

func dbPropToModel(dbProp db.Property) (*properties.Property, error) {
anyVal, err := db.PropValueFromDbV1(dbProp.Value)
if err != nil {
return nil, err
}

return properties.NewProperty(anyVal)
}
Loading
Loading