fleet/server/datastore/cached_mysql/cached_mysql.go
Roberto Dip 545e56d288
19016 ingest certs on start (#19360)
For #19016

This changes all the places where we previously assumed that certs were
hardcoded when the Fleet server started to query the database instead.

The plan is to loadtest afterwards, but as a first preemptive measure,
this adds a caching layer on top the mysql datastore.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality
2024-05-30 18:18:42 -03:00

450 lines
13 KiB
Go

package cached_mysql
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/patrickmn/go-cache"
)
// NOTE: To add a new cached item, make sure you know how/when to invalidate it
// and how long it can safely be cached. Consider the case where it is read to
// be updated - those cases need to bypass the cache and read directly from the
// DB to always use fresh data (see the ctxdb.BypassCachedMysql method). Follow
// all of these steps:
//
// 1. Add a unique key name and a default expiration duration, which will be
// used in production.
// 2. Define an expiration duration field in the cachedMysql struct,
// initialize it with the default expiration duration in New, and add a
// WithXXXExpiration option to customize it.
// 3. Implement the cloner interface for the type of the cached item. If the
// type is a slice, you will need to define a type for the slice (see
// fleet.ScheduledQueryList for an example, or packsList in this package
// for an alternative approach).
// 4. Add the cached item to fleet/tools/cloner-check/main.go (in the
// cacheableItems slice variable) to ensure it gets properly checked in CI
// when fields are added/modified. Run the tool to update the generated files
// once you're confident that the Clone implementation covers all fields that
// need special care (usually pointers, slices, maps).
// 5. Add the required Datastore methods to get the cached item and to set it,
// and add tests in cached_mysql_test.go to ensure it works as expected.
const (
appConfigKey = "AppConfig:%s"
defaultAppConfigExpiration = 1 * time.Second
packsHostKey = "Packs:host:%d"
defaultPacksExpiration = 1 * time.Minute
scheduledQueriesKey = "ScheduledQueries:pack:%d"
defaultScheduledQueriesExpiration = 1 * time.Minute
teamAgentOptionsKey = "TeamAgentOptions:team:%d"
defaultTeamAgentOptionsExpiration = 1 * time.Minute
teamFeaturesKey = "TeamFeatures:team:%d"
defaultTeamFeaturesExpiration = 1 * time.Minute
teamMDMConfigKey = "TeamMDMConfig:team:%d"
defaultTeamMDMConfigExpiration = 1 * time.Minute
queryByNameKey = "QueryByName:team:%d:%s"
defaultQueryByNameExpiration = 1 * time.Second
queryResultsCountKey = "QueryResultsCount:%d"
defaultQueryResultsCountExpiration = 1 * time.Second
// NOTE: MDM assets are cached using their checksum as well, as it's
// important for them to always be fresh if they changed (see cachedi
// mplementation below for details)
mdmConfigAssetKey = "MDMConfigAsset:%s:%s"
// NOTE: given how mdmConfigAssetKey works, it means that once an asset
// changes, it'll linger for this amount of time. The curent
// implementation assumes infrequent asset changes.
defaultMDMConfigAssetExpiration = 15 * time.Minute
)
// cloneCache wraps the in memory cache with one that clones items before returning them.
type cloneCache struct {
*cache.Cache
}
func (c *cloneCache) Get(ctx context.Context, k string) (fleet.Cloner, bool) {
if ctxdb.IsCachedMysqlBypassed(ctx) {
// cache miss if the caller explicitly asked to bypass the cache
return nil, false
}
x, found := c.Cache.Get(k)
if !found {
return nil, false
}
xc, ok := x.(fleet.Cloner)
if !ok {
// should never happen, cached item is not a cloner
return nil, false
}
clone, err := xc.Clone()
if err != nil {
// Unfortunely, we can't return an error here. Return a cache miss instead of panic'ing.
return nil, false
}
return clone, true
}
func (c *cloneCache) Set(ctx context.Context, k string, x fleet.Cloner, d time.Duration) {
clone, err := x.Clone()
if err != nil {
// Unfortunately, we can't return an error here. Skip caching it if clone
// fails, but ensure that we clear any existing cached item for this key,
// as the call to Set indicates the cache is now stale.
c.Cache.Delete(k)
return
}
c.Cache.Set(k, clone, d)
}
type cachedMysql struct {
fleet.Datastore
c *cloneCache
appConfigExp time.Duration
packsExp time.Duration
scheduledQueriesExp time.Duration
teamAgentOptionsExp time.Duration
teamFeaturesExp time.Duration
teamMDMConfigExp time.Duration
queryByNameExp time.Duration
queryResultsCountExp time.Duration
mdmConfigAssetExp time.Duration
}
type Option func(*cachedMysql)
func WithAppConfigExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.appConfigExp = d
}
}
func WithPacksExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.packsExp = d
}
}
func WithScheduledQueriesExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.scheduledQueriesExp = d
}
}
func WithTeamAgentOptionsExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.teamAgentOptionsExp = d
}
}
func WithTeamFeaturesExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.teamFeaturesExp = d
}
}
func WithTeamMDMConfigExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.teamMDMConfigExp = d
}
}
func WithQueryByNameExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.queryByNameExp = d
}
}
func WithQueryResultsCountExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.queryResultsCountExp = d
}
}
func WithMDMConfigAssetExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.mdmConfigAssetExp = d
}
}
func New(ds fleet.Datastore, opts ...Option) fleet.Datastore {
c := &cachedMysql{
Datastore: ds,
c: &cloneCache{cache.New(5*time.Minute, 10*time.Minute)},
appConfigExp: defaultAppConfigExpiration,
packsExp: defaultPacksExpiration,
scheduledQueriesExp: defaultScheduledQueriesExpiration,
teamAgentOptionsExp: defaultTeamAgentOptionsExpiration,
teamFeaturesExp: defaultTeamFeaturesExpiration,
teamMDMConfigExp: defaultTeamMDMConfigExpiration,
queryByNameExp: defaultQueryByNameExpiration,
queryResultsCountExp: defaultQueryResultsCountExpiration,
mdmConfigAssetExp: defaultMDMConfigAssetExpiration,
}
for _, fn := range opts {
fn(c)
}
return c
}
func (ds *cachedMysql) NewAppConfig(ctx context.Context, info *fleet.AppConfig) (*fleet.AppConfig, error) {
ac, err := ds.Datastore.NewAppConfig(ctx, info)
if err != nil {
return nil, err
}
ds.c.Set(ctx, appConfigKey, ac, ds.appConfigExp)
return ac, nil
}
func (ds *cachedMysql) AppConfig(ctx context.Context) (*fleet.AppConfig, error) {
if x, found := ds.c.Get(ctx, appConfigKey); found {
ac, ok := x.(*fleet.AppConfig)
if ok {
return ac, nil
}
}
ac, err := ds.Datastore.AppConfig(ctx)
if err != nil {
return nil, err
}
ds.c.Set(ctx, appConfigKey, ac, ds.appConfigExp)
return ac, nil
}
func (ds *cachedMysql) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) error {
err := ds.Datastore.SaveAppConfig(ctx, info)
if err != nil {
return err
}
ds.c.Set(ctx, appConfigKey, info, ds.appConfigExp)
return nil
}
func (ds *cachedMysql) ListPacksForHost(ctx context.Context, hid uint) ([]*fleet.Pack, error) {
key := fmt.Sprintf(packsHostKey, hid)
if x, found := ds.c.Get(ctx, key); found {
cachedPacks, ok := x.(packsList)
if ok {
return cachedPacks, nil
}
}
packs, err := ds.Datastore.ListPacksForHost(ctx, hid)
if err != nil {
return nil, err
}
ds.c.Set(ctx, key, packsList(packs), ds.packsExp)
return packs, nil
}
func (ds *cachedMysql) ListScheduledQueriesInPack(ctx context.Context, packID uint) (fleet.ScheduledQueryList, error) {
key := fmt.Sprintf(scheduledQueriesKey, packID)
if x, found := ds.c.Get(ctx, key); found {
scheduledQueries, ok := x.(fleet.ScheduledQueryList)
if ok {
return scheduledQueries, nil
}
}
scheduledQueries, err := ds.Datastore.ListScheduledQueriesInPack(ctx, packID)
if err != nil {
return nil, err
}
ds.c.Set(ctx, key, scheduledQueries, ds.scheduledQueriesExp)
return scheduledQueries, nil
}
func (ds *cachedMysql) TeamAgentOptions(ctx context.Context, teamID uint) (*json.RawMessage, error) {
key := fmt.Sprintf(teamAgentOptionsKey, teamID)
if x, found := ds.c.Get(ctx, key); found {
if agentOptions, ok := x.(*rawJSONMessage); ok {
return (*json.RawMessage)(agentOptions), nil
}
}
agentOptions, err := ds.Datastore.TeamAgentOptions(ctx, teamID)
if err != nil {
return nil, err
}
ds.c.Set(ctx, key, (*rawJSONMessage)(agentOptions), ds.teamAgentOptionsExp)
return agentOptions, nil
}
func (ds *cachedMysql) TeamFeatures(ctx context.Context, teamID uint) (*fleet.Features, error) {
key := fmt.Sprintf(teamFeaturesKey, teamID)
if x, found := ds.c.Get(ctx, key); found {
if features, ok := x.(*fleet.Features); ok {
return features, nil
}
}
features, err := ds.Datastore.TeamFeatures(ctx, teamID)
if err != nil {
return nil, err
}
ds.c.Set(ctx, key, features, ds.teamFeaturesExp)
return features, nil
}
func (ds *cachedMysql) TeamMDMConfig(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
key := fmt.Sprintf(teamMDMConfigKey, teamID)
if x, found := ds.c.Get(ctx, key); found {
if cfg, ok := x.(*fleet.TeamMDM); ok {
return cfg, nil
}
}
cfg, err := ds.Datastore.TeamMDMConfig(ctx, teamID)
if err != nil {
return nil, err
}
ds.c.Set(ctx, key, cfg, ds.teamMDMConfigExp)
return cfg, nil
}
func (ds *cachedMysql) SaveTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
team, err := ds.Datastore.SaveTeam(ctx, team)
if err != nil {
return nil, err
}
agentOptionsKey := fmt.Sprintf(teamAgentOptionsKey, team.ID)
featuresKey := fmt.Sprintf(teamFeaturesKey, team.ID)
mdmConfigKey := fmt.Sprintf(teamMDMConfigKey, team.ID)
ds.c.Set(ctx, agentOptionsKey, (*rawJSONMessage)(team.Config.AgentOptions), ds.teamAgentOptionsExp)
ds.c.Set(ctx, featuresKey, &team.Config.Features, ds.teamFeaturesExp)
ds.c.Set(ctx, mdmConfigKey, &team.Config.MDM, ds.teamMDMConfigExp)
return team, nil
}
func (ds *cachedMysql) DeleteTeam(ctx context.Context, teamID uint) error {
err := ds.Datastore.DeleteTeam(ctx, teamID)
if err != nil {
return err
}
agentOptionsKey := fmt.Sprintf(teamAgentOptionsKey, teamID)
featuresKey := fmt.Sprintf(teamFeaturesKey, teamID)
mdmConfigKey := fmt.Sprintf(teamMDMConfigKey, teamID)
ds.c.Delete(agentOptionsKey)
ds.c.Delete(featuresKey)
ds.c.Delete(mdmConfigKey)
return nil
}
// TODO: should we handle DeleteQuery/DeleteQueries/SaveQuery to invalidate that cache?
func (ds *cachedMysql) QueryByName(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
teamID_ := uint(0) // global team is 0
if teamID != nil {
teamID_ = *teamID
}
key := fmt.Sprintf(queryByNameKey, teamID_, name)
if x, found := ds.c.Get(ctx, key); found {
if query, ok := x.(*fleet.Query); ok {
return query, nil
}
}
query, err := ds.Datastore.QueryByName(ctx, teamID, name)
if err != nil {
return nil, err
}
ds.c.Set(ctx, key, query, ds.queryByNameExp)
return query, nil
}
func (ds *cachedMysql) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) {
key := fmt.Sprintf(queryResultsCountKey, queryID)
if x, found := ds.c.Get(ctx, key); found {
if count, ok := x.(integer); ok {
return int(count), nil
}
}
count, err := ds.Datastore.ResultCountForQuery(ctx, queryID)
if err != nil {
return 0, err
}
ds.c.Set(ctx, key, integer(count), ds.queryResultsCountExp)
return count, nil
}
func (ds *cachedMysql) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
// always reach the database to get the latest hashes
latestHashes, err := ds.Datastore.GetAllMDMConfigAssetsHashes(ctx, assetNames)
if err != nil {
return nil, err
}
cachedAssets := make(map[fleet.MDMAssetName]fleet.MDMConfigAsset)
var missingAssets []fleet.MDMAssetName
var missingKeys []string
for _, name := range assetNames {
key := fmt.Sprintf(mdmConfigAssetKey, name, latestHashes[name])
if x, found := ds.c.Get(ctx, key); found {
asset, ok := x.(fleet.MDMConfigAsset)
if ok {
cachedAssets[name] = asset
continue
}
}
missingAssets = append(missingAssets, name)
missingKeys = append(missingKeys, key)
}
if len(missingAssets) == 0 {
return cachedAssets, nil
}
// fetch missing assets from the database
assetMap, err := ds.Datastore.GetAllMDMConfigAssetsByName(ctx, missingAssets)
if err != nil {
return nil, err
}
// update the cache with the fetched assets and their hashes
for name, asset := range assetMap {
key := fmt.Sprintf(mdmConfigAssetKey, name, latestHashes[name])
ds.c.Set(ctx, key, asset, ds.mdmConfigAssetExp)
cachedAssets[name] = asset
}
return cachedAssets, nil
}