1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-11-30 08:36:54 +02:00

implement data retention for boards (#2588)

* implement data retention for boards

* fix lint errors

* start of orphans

* add and update tests

* fixes for merge

* fix tests

* fix lint

* reset from testing

* update setting unit test variable

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
Scott Bishel 2022-04-18 09:03:42 -06:00 committed by GitHub
parent 21667aa8d0
commit 0489de8bd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 436 additions and 32 deletions

View File

@ -214,6 +214,7 @@ detach: setup-attach
## Runs any lints and unit tests defined for the server and webapp, if they exist.
.PHONY: test
test: export FB_UNIT_TESTING=1
test: webapp/node_modules
ifneq ($(HAS_SERVER),)
$(GO) test -v $(GO_TEST_FLAGS) ./server/...

View File

@ -3,8 +3,10 @@ module github.com/mattermost/focalboard/mattermost-plugin
go 1.16
replace github.com/mattermost/focalboard/server => ../server
replace github.com/mattermost/focalboard/server/server => ../server/server
require (
github.com/golang/mock v1.6.0
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
github.com/mattermost/mattermost-plugin-api v0.0.27
github.com/mattermost/mattermost-server/v6 v6.5.0

View File

@ -88,6 +88,11 @@ func (p *Plugin) OnConfigurationChange() error { //nolint
// handle feature flags
p.server.Config().FeatureFlags = parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
// handle Data Retention settings
p.server.Config().EnableDataRetention = *mmconfig.DataRetentionSettings.EnableBoardsDeletion
p.server.Config().DataRetentionDays = *mmconfig.DataRetentionSettings.BoardsRetentionDays
p.server.UpdateAppConfig()
p.wsPluginAdapter.BroadcastConfigChange(*p.server.App().GetClientConfig())
return nil

View File

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/ws"
serverModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
@ -13,27 +14,6 @@ import (
"github.com/stretchr/testify/assert"
)
type TestHelper struct {
Server *server.Server
}
func SetupTestHelper() *TestHelper {
th := &TestHelper{}
th.Server = newTestServer()
return th
}
func newTestServer() *server.Server {
srv, err := server.New(server.Params{
Cfg: &config.Configuration{},
})
if err != nil {
panic(err)
}
return srv
}
func TestConfigurationNullConfiguration(t *testing.T) {
plugin := &Plugin{}
assert.NotNil(t, plugin.getConfiguration())
@ -53,16 +33,24 @@ func TestOnConfigurationChange(t *testing.T) {
Directory: &stringRef,
Plugins: basePlugins,
}
falseRef := false
intRef := 365
baseDataRetentionSettings := &serverModel.DataRetentionSettings{
EnableBoardsDeletion: &falseRef,
BoardsRetentionDays: &intRef,
}
baseConfig := &serverModel.Config{
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
DataRetentionSettings: *baseDataRetentionSettings,
}
t.Run("Test Load Plugin Success", func(t *testing.T) {
th := SetupTestHelper()
th := SetupTestHelper(t)
api := &plugintest.API{}
api.On("GetUnsanitizedConfig").Return(baseConfig)
api.On("GetConfig").Return(baseConfig)
p := Plugin{}
p.SetAPI(api)

View File

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/permissions/localpermissions"
"github.com/mattermost/focalboard/server/services/store/mockstore"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
type TestHelper struct {
Server *server.Server
Store *mockstore.MockStore
}
func SetupTestHelper(t *testing.T) *TestHelper {
th := &TestHelper{}
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := mockstore.NewMockStore(ctrl)
th.Server = newTestServer(t, mockStore)
th.Store = mockStore
return th
}
func newTestServer(t *testing.T, mockStore *mockstore.MockStore) *server.Server {
config := &config.Configuration{
EnableDataRetention: false,
DataRetentionDays: 10,
FilesDriver: "local",
FilesPath: "./files",
WebPath: "/",
}
logger, err := mlog.NewLogger()
if err = logger.Configure("", config.LoggingCfgJSON, nil); err != nil {
panic(err)
}
mockStore.EXPECT().GetTeam(gomock.Any()).Return(nil, nil).AnyTimes()
mockStore.EXPECT().UpsertTeamSignupToken(gomock.Any()).AnyTimes()
mockStore.EXPECT().GetSystemSettings().AnyTimes()
mockStore.EXPECT().SetSystemSetting(gomock.Any(), gomock.Any()).AnyTimes()
permissionsService := localpermissions.New(mockStore, logger)
srv, err := server.New(server.Params{
Cfg: config,
DBStore: mockStore,
Logger: logger,
PermissionsService: permissionsService,
SkipTemplateInit: true,
})
if err != nil {
panic(err)
}
return srv
}

View File

@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
@ -9,6 +10,7 @@ import (
"path"
"strings"
"sync"
"time"
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/server"
@ -38,6 +40,8 @@ const (
notifyFreqBoardSecondsKey = "notify_freq_board_seconds"
)
var ErrInsufficientLicense = errors.New("appropriate license required")
type BoardsEmbed struct {
OriginalPath string `json:"originalPath"`
TeamID string `json:"teamID"`
@ -236,6 +240,8 @@ func (p *Plugin) createBoardsConfig(mmconfig mmModel.Config, baseURL string, ser
FeatureFlags: featureFlags,
NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120),
NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400),
EnableDataRetention: *mmconfig.DataRetentionSettings.EnableBoardsDeletion,
DataRetentionDays: *mmconfig.DataRetentionSettings.BoardsRetentionDays,
}
}
@ -502,3 +508,24 @@ func isBoardsLink(link string) bool {
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
return teamID != "" && boardID != "" && viewID != "" && cardID != ""
}
func (p *Plugin) RunDataRetention(nowTime, batchSize int64) (int64, error) {
p.server.Logger().Debug("Boards RunDataRetention")
license := p.server.Store().GetLicense()
if license == nil || !(*license.Features.DataRetention) {
return 0, ErrInsufficientLicense
}
if p.server.Config().EnableDataRetention {
boardsRetentionDays := p.server.Config().DataRetentionDays
endTimeBoards := convertDaysToCutoff(boardsRetentionDays, time.Unix(nowTime/1000, 0))
return p.server.Store().RunDataRetention(endTimeBoards, batchSize)
}
return 0, nil
}
func convertDaysToCutoff(days int, now time.Time) int64 {
upToStartOfDay := now.AddDate(0, 0, -days)
cutoffDate := time.Date(upToStartOfDay.Year(), upToStartOfDay.Month(), upToStartOfDay.Day(), 0, 0, 0, 0, time.Local)
return cutoffDate.UnixNano() / int64(time.Millisecond)
}

View File

@ -1,21 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func testHandler(w http.ResponseWriter, r *http.Request) {
// A very simple health check.
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
// In the future we could report back on the status of our DB, or our cache
// (e.g. Redis) by performing a simple PING, and include them in the response.
io.WriteString(w, "Hello, world!")
}
func TestServeHTTP(t *testing.T) {
assert := assert.New(t)
th := SetupTestHelper(t)
plugin := Plugin{}
testHandler := http.HandlerFunc(testHandler)
th.Server.GetRootRouter().Handle("/", testHandler)
plugin.server = th.Server
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
// plugin.server.
plugin.ServeHTTP(nil, w, r)
result := w.Result()
@ -31,6 +54,7 @@ func TestServeHTTP(t *testing.T) {
func TestSetConfiguration(t *testing.T) {
plugin := Plugin{}
boolTrue := true
boolFalse := false
stringRef := ""
baseFeatureFlags := &model.FeatureFlags{}
@ -45,16 +69,25 @@ func TestSetConfiguration(t *testing.T) {
}
directory := "testDirectory"
maxFileSize := int64(1048576000)
baseFileSettings := &model.FileSettings{
DriverName: &driverName,
Directory: &directory,
DriverName: &driverName,
Directory: &directory,
MaxFileSize: &maxFileSize,
}
days := 365
baseDataRetentionSettings := &model.DataRetentionSettings{
EnableBoardsDeletion: &boolFalse,
BoardsRetentionDays: &days,
}
baseConfig := &model.Config{
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
SqlSettings: *baseSQLSettings,
FileSettings: *baseFileSettings,
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
SqlSettings: *baseSQLSettings,
FileSettings: *baseFileSettings,
DataRetentionSettings: *baseDataRetentionSettings,
}
t.Run("test enable telemetry", func(t *testing.T) {
@ -96,3 +129,65 @@ func TestSetConfiguration(t *testing.T) {
assert.Equal(t, "true", config.FeatureFlags["myTest"])
})
}
func TestRunDataRetention(t *testing.T) {
th := SetupTestHelper(t)
plugin := Plugin{}
plugin.server = th.Server
now := time.Now().UnixNano()
t.Run("test null license", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(nil)
_, err := plugin.RunDataRetention(now, 10)
assert.NotNil(t, err)
assert.Equal(t, ErrInsufficientLicense, err)
})
t.Run("test invalid license", func(t *testing.T) {
falseValue := false
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &falseValue,
},
},
)
_, err := plugin.RunDataRetention(now, 10)
assert.NotNil(t, err)
assert.Equal(t, ErrInsufficientLicense, err)
})
t.Run("test valid license, invalid config", func(t *testing.T) {
trueValue := true
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &trueValue,
},
})
count, err := plugin.RunDataRetention(now, 10)
assert.Nil(t, err)
assert.Equal(t, int64(0), count)
})
t.Run("test valid license, valid config", func(t *testing.T) {
trueValue := true
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &trueValue,
},
})
th.Store.EXPECT().RunDataRetention(gomock.Any(), int64(10)).Return(int64(100), nil)
plugin.server.Config().EnableDataRetention = true
count, err := plugin.RunDataRetention(now, 10)
assert.Nil(t, err)
assert.Equal(t, int64(100), count)
})
}

View File

@ -21,6 +21,7 @@ type Params struct {
WSAdapter ws.Adapter
NotifyBackends []notify.Backend
PermissionsService permissions.PermissionsService
SkipTemplateInit bool
}
func (p Params) CheckValid() error {

View File

@ -357,6 +357,10 @@ func (s *Server) App() *app.App {
return s.app
}
func (s *Server) Store() store.Store {
return s.store
}
func (s *Server) UpdateAppConfig() {
s.app.SetConfig(s.config)
}

View File

@ -50,6 +50,8 @@ type Configuration struct {
LocalModeSocketLocation string `json:"localModeSocketLocation" mapstructure:"localModeSocketLocation"`
EnablePublicSharedBoards bool `json:"enablePublicSharedBoards" mapstructure:"enablePublicSharedBoards"`
FeatureFlags map[string]string `json:"featureFlags" mapstructure:"featureFlags"`
EnableDataRetention bool `json:"enable_data_retention" mapstructure:"enable_data_retention"`
DataRetentionDays int `json:"data_retention_days" mapstructure:"data_retention_days"`
AuthMode string `json:"authMode" mapstructure:"authMode"`
@ -95,6 +97,8 @@ func ReadConfigFile(configFilePath string) (*Configuration, error) {
viper.SetDefault("AuthMode", "native")
viper.SetDefault("NotifyFreqCardSeconds", 120) // 2 minutes after last card edit
viper.SetDefault("NotifyFreqBoardSeconds", 86400) // 1 day after last card edit
viper.SetDefault("EnableDataRetention", false)
viper.SetDefault("DataRetentionDays", 365) // 1 year is default
viper.SetDefault("PrometheusAddress", "")
err := viper.ReadInConfig() // Find and read the config file

View File

@ -1128,6 +1128,21 @@ func (mr *MockStoreMockRecorder) RemoveDefaultTemplates(arg0 interface{}) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveDefaultTemplates", reflect.TypeOf((*MockStore)(nil).RemoveDefaultTemplates), arg0)
}
// RunDataRetention mocks base method.
func (m *MockStore) RunDataRetention(arg0, arg1 int64) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RunDataRetention", arg0, arg1)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RunDataRetention indicates an expected call of RunDataRetention.
func (mr *MockStoreMockRecorder) RunDataRetention(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunDataRetention", reflect.TypeOf((*MockStore)(nil).RunDataRetention), arg0, arg1)
}
// SaveMember mocks base method.
func (m *MockStore) SaveMember(arg0 *model.BoardMember) (*model.BoardMember, error) {
m.ctrl.T.Helper()

View File

@ -4,9 +4,13 @@ import (
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/utils"
"github.com/pkg/errors"
sq "github.com/Masterminds/squirrel"
_ "github.com/lib/pq" // postgres driver
@ -744,6 +748,113 @@ func (s *SQLStore) replaceBlockID(db sq.BaseRunner, currentID, newID, workspaceI
return nil
}
func (s *SQLStore) runDataRetention(db sq.BaseRunner, globalRetentionDate int64, batchSize int64) (int64, error) {
mlog.Info("Start Boards Data Retention",
mlog.String("Global Retention Date", time.Unix(globalRetentionDate/1000, 0).String()),
mlog.Int64("Raw Date", globalRetentionDate))
deleteTables := map[string]string{
"blocks": "board_id",
"blocks_history": "board_id",
"boards": "id",
"boards_history": "id",
"board_members": "board_id",
"sharing": "id",
}
subBuilder := s.getQueryBuilder(db).
Select("board_id, MAX(update_at) AS maxDate").
From(s.tablePrefix + "blocks").
GroupBy("board_id")
subQuery, _, _ := subBuilder.ToSql()
builder := s.getQueryBuilder(db).
Select("id").
From(s.tablePrefix + "boards").
LeftJoin("( " + subQuery + " ) As subquery ON (subquery.board_id = id)").
Where(sq.Lt{"maxDate": globalRetentionDate}).
Where(sq.NotEq{"team_id": "0"}).
Where(sq.Eq{"is_template": false})
rows, err := builder.Query()
if err != nil {
s.logger.Error(`dataRetention subquery ERROR`, mlog.Err(err))
return 0, err
}
defer s.CloseRows(rows)
deleteIds, err := idsFromRows(rows)
if err != nil {
return 0, err
}
totalAffected := 0
if len(deleteIds) > 0 {
mlog.Debug("Data Retention DeleteIDs " + strings.Join(deleteIds, ", "))
for table, field := range deleteTables {
affected, err := s.genericRetentionPoliciesDeletion(db, table, field, deleteIds, batchSize)
if err != nil {
return int64(totalAffected), err
}
totalAffected += int(affected)
}
}
mlog.Info("Complete Boards Data Retention", mlog.Int("total deletion ids", len(deleteIds)), mlog.Int("TotalAffected", totalAffected))
return int64(totalAffected), nil
}
func idsFromRows(rows *sql.Rows) ([]string, error) {
deleteIds := []string{}
for rows.Next() {
var boardID string
err := rows.Scan(
&boardID,
)
if err != nil {
return nil, err
}
deleteIds = append(deleteIds, boardID)
}
return deleteIds, nil
}
// genericRetentionPoliciesDeletion actually executes the DELETE query using a sq.SelectBuilder
// which selects the rows to delete.
func (s *SQLStore) genericRetentionPoliciesDeletion(
db sq.BaseRunner,
table string,
deleteColumn string,
deleteIds []string,
batchSize int64,
) (int64, error) {
whereClause := deleteColumn + ` IN ('` + strings.Join(deleteIds, `','`) + `')`
deleteQuery := s.getQueryBuilder(db).
Delete(s.tablePrefix + table).
Where(whereClause)
s1, _, _ := deleteQuery.ToSql()
mlog.Debug(s1)
var totalRowsAffected int64
var batchRowsAffected int64
for {
result, err := deleteQuery.Exec()
if err != nil {
return 0, errors.Wrap(err, "failed to delete "+table)
}
batchRowsAffected, err = result.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "failed to get rows affected for "+table)
}
totalRowsAffected += batchRowsAffected
if batchRowsAffected != batchSize {
break
}
}
mlog.Debug("Rows Affected" + strconv.FormatInt(totalRowsAffected, 10))
return totalRowsAffected, nil
}
func (s *SQLStore) duplicateBlock(db sq.BaseRunner, boardID string, blockID string, userID string, asTemplate bool) ([]model.Block, error) {
blocks, err := s.getSubTree2(db, boardID, blockID, model.QuerySubtreeOptions{})
if err != nil {

View File

@ -18,6 +18,7 @@ type Params struct {
IsPlugin bool
NewMutexFn MutexFactory
PluginAPI *plugin.API
SkipTemplateInit bool
}
func (p Params) CheckValid() error {

View File

@ -648,6 +648,30 @@ func (s *SQLStore) RemoveDefaultTemplates(boards []*model.Board) error {
}
func (s *SQLStore) RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error) {
if s.dbType == model.SqliteDBType {
return s.runDataRetention(s.db, globalRetentionDate, batchSize)
}
tx, txErr := s.db.BeginTx(context.Background(), nil)
if txErr != nil {
return 0, txErr
}
result, err := s.runDataRetention(tx, globalRetentionDate, batchSize)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "RunDataRetention"))
}
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return result, nil
}
func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error) {
return s.saveMember(s.db, bm)

View File

@ -134,6 +134,9 @@ type Store interface {
RemoveDefaultTemplates(boards []*model.Board) error
GetTemplateBoards(teamID, userID string) ([]*model.Board, error)
// @withTransaction
RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error)
DBType() string
IsErrNotFound(err error) bool

View File

@ -69,6 +69,11 @@ func StoreTestBlocksStore(t *testing.T, setup func(t *testing.T) (store.Store, f
defer tearDown()
testDuplicateBlock(t, store)
})
t.Run("RunDataRetention", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testRunDataRetention(t, store)
})
t.Run("GetBlockMetadata", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
@ -795,6 +800,56 @@ func testGetBlock(t *testing.T, store store.Store) {
})
}
func testRunDataRetention(t *testing.T, store store.Store) {
validBoard := model.Board{
ID: "board-id-test",
IsTemplate: false,
ModifiedBy: "user-id-1",
TeamID: "team-id",
}
board, err := store.InsertBoard(&validBoard, "user-id-1")
require.NoError(t, err)
validBlock := model.Block{
ID: "id-test",
BoardID: board.ID,
ModifiedBy: "user-id-1",
}
validBlock2 := model.Block{
ID: "id-test2",
BoardID: board.ID,
ModifiedBy: "user-id-1",
}
newBlocks := []model.Block{validBlock, validBlock2}
err = store.InsertBlocks(newBlocks, "user-id-1")
require.NoError(t, err)
blocks, err := store.GetBlocksWithBoardID(board.ID)
require.NoError(t, err)
require.Len(t, blocks, len(newBlocks))
initialCount := len(blocks)
t.Run("test no deletions", func(t *testing.T) {
deletions, err := store.RunDataRetention(utils.GetMillisForTime(time.Now().Add(-time.Hour*1)), 10)
require.NoError(t, err)
require.Equal(t, int64(0), deletions)
})
t.Run("test all deletions", func(t *testing.T) {
deletions, err := store.RunDataRetention(utils.GetMillisForTime(time.Now().Add(time.Hour*1)), 10)
require.NoError(t, err)
require.True(t, deletions > int64(initialCount))
// expect all blocks to be deleted.
blocks, errBlocks := store.GetBlocksWithBoardID(board.ID)
require.NoError(t, errBlocks)
require.Equal(t, 0, len(blocks))
})
}
func testDuplicateBlock(t *testing.T, store store.Store) {
InsertBlocks(t, store, subtreeSampleBlocks, "user-id-1")
time.Sleep(1 * time.Millisecond)