github.com/cilium/statedb@v0.3.2/reconciler/script_test.go (about)

     1  package reconciler_test
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"expvar"
     7  	"fmt"
     8  	"iter"
     9  	"maps"
    10  	"slices"
    11  	"sort"
    12  	"strconv"
    13  	"strings"
    14  	"sync"
    15  	"sync/atomic"
    16  	"testing"
    17  	"time"
    18  
    19  	"github.com/cilium/hive"
    20  	"github.com/cilium/hive/cell"
    21  	"github.com/cilium/hive/hivetest"
    22  	"github.com/cilium/hive/job"
    23  	"github.com/cilium/hive/script"
    24  	"github.com/cilium/hive/script/scripttest"
    25  	"github.com/cilium/statedb"
    26  	"github.com/cilium/statedb/index"
    27  	"github.com/cilium/statedb/reconciler"
    28  	"github.com/stretchr/testify/assert"
    29  	"github.com/stretchr/testify/require"
    30  	"golang.org/x/time/rate"
    31  )
    32  
    33  func newScriptTest(t *testing.T) *script.Engine {
    34  	log := hivetest.Logger(t)
    35  
    36  	var (
    37  		ops                 = &mockOps{}
    38  		db                  *statedb.DB
    39  		r                   reconciler.Reconciler[*testObject]
    40  		reconcilerParams    reconciler.Params
    41  		reconcilerLifecycle = &cell.DefaultLifecycle{}
    42  		markInit            func()
    43  	)
    44  
    45  	expVarMetrics := reconciler.NewUnpublishedExpVarMetrics()
    46  
    47  	testObjects, err := statedb.NewTable("test-objects", idIndex)
    48  	require.NoError(t, err, "NewTable")
    49  
    50  	hive := hive.New(
    51  		statedb.Cell,
    52  		job.Cell,
    53  
    54  		cell.Provide(
    55  			cell.NewSimpleHealth,
    56  			func(h *cell.SimpleHealth) hive.ScriptCmdOut {
    57  				return hive.NewScriptCmd(
    58  					"health",
    59  					cell.SimpleHealthCmd(h))
    60  			},
    61  		),
    62  
    63  		cell.Module(
    64  			"test",
    65  			"Test",
    66  
    67  			cell.Provide(
    68  				func() reconciler.Metrics {
    69  					return expVarMetrics
    70  				}),
    71  
    72  			cell.Invoke(
    73  				func(db_ *statedb.DB, p_ reconciler.Params) error {
    74  					db = db_
    75  					reconcilerParams = p_
    76  					return db.RegisterTable(testObjects)
    77  				},
    78  
    79  				func(lc cell.Lifecycle) {
    80  					lc.Append(cell.Hook{
    81  						OnStop: func(ctx cell.HookContext) error { return reconcilerLifecycle.Stop(log, ctx) },
    82  					})
    83  				},
    84  
    85  				func(h *cell.SimpleHealth) {
    86  					wtxn := db.WriteTxn(testObjects)
    87  					done := testObjects.RegisterInitializer(wtxn, "test")
    88  					wtxn.Commit()
    89  					markInit = func() {
    90  						wtxn := db.WriteTxn(testObjects)
    91  						done(wtxn)
    92  						wtxn.Commit()
    93  					}
    94  				}),
    95  		),
    96  	)
    97  
    98  	cmds, err := hive.ScriptCommands(log)
    99  	require.NoError(t, err)
   100  
   101  	cmds["mark-init"] = script.Command(
   102  		script.CmdUsage{Summary: "Mark table as initialized"},
   103  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   104  			markInit()
   105  			return nil, nil
   106  		},
   107  	)
   108  
   109  	cmds["start-reconciler"] = script.Command(
   110  		script.CmdUsage{Summary: "Mark table as initialized"},
   111  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   112  			opts := []reconciler.Option{
   113  				// Speed things up a bit. Quick retry interval does mean we can't
   114  				// assert the metrics exactly (e.g. error count depends on how
   115  				// many retries happened).
   116  				reconciler.WithRetry(50*time.Millisecond, 50*time.Millisecond),
   117  				reconciler.WithRoundLimits(1000, rate.NewLimiter(1000.0, 10)),
   118  			}
   119  			var bops reconciler.BatchOperations[*testObject]
   120  			for _, arg := range args {
   121  				switch arg {
   122  				case "with-prune":
   123  					opts = append(opts, reconciler.WithPruning(time.Hour))
   124  				case "with-refresh":
   125  					opts = append(opts, reconciler.WithRefreshing(50*time.Millisecond, rate.NewLimiter(100.0, 1)))
   126  				case "with-batchops":
   127  					bops = ops
   128  				default:
   129  					return nil, fmt.Errorf("unexpected arg, expected 'with-prune', 'with-batchops' or 'with-refresh'")
   130  				}
   131  			}
   132  			reconcilerParams.Lifecycle = reconcilerLifecycle
   133  			r, err = reconciler.Register(
   134  				reconcilerParams,
   135  				testObjects,
   136  				(*testObject).Clone,
   137  				(*testObject).SetStatus,
   138  				(*testObject).GetStatus,
   139  				ops,
   140  				bops,
   141  				opts...)
   142  			if err != nil {
   143  				return nil, err
   144  			}
   145  			return nil, reconcilerLifecycle.Start(log, context.TODO())
   146  		},
   147  	)
   148  
   149  	cmds["prune"] = script.Command(
   150  		script.CmdUsage{Summary: "Trigger pruning"},
   151  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   152  			r.Prune()
   153  			return nil, nil
   154  		},
   155  	)
   156  
   157  	cmds["set-faulty"] = script.Command(
   158  		script.CmdUsage{Summary: "Mark target faulty or not"},
   159  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   160  			if args[0] == "true" {
   161  				t.Logf("Marked target faulty")
   162  				ops.faulty.Store(true)
   163  			} else {
   164  				t.Logf("Marked target healthy")
   165  				ops.faulty.Store(false)
   166  			}
   167  			return nil, nil
   168  		},
   169  	)
   170  
   171  	cmds["expect-ops"] = script.Command(
   172  		script.CmdUsage{Summary: "Assert ops"},
   173  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   174  			sort.Strings(args)
   175  			var actual []string
   176  			cond := func() bool {
   177  				actual = ops.history.take(len(args))
   178  				sort.Strings(actual)
   179  				return slices.Equal(args, actual)
   180  			}
   181  			for s.Context().Err() == nil {
   182  				if cond() {
   183  					return nil, nil
   184  				}
   185  			}
   186  			return nil, fmt.Errorf("operations mismatch, expected %v, got %v", args, actual)
   187  		},
   188  	)
   189  
   190  	cmds["expvar"] = script.Command(
   191  		script.CmdUsage{Summary: "Print expvars to stdout"},
   192  		func(s *script.State, args ...string) (script.WaitFunc, error) {
   193  			return func(*script.State) (stdout, stderr string, err error) {
   194  				var buf strings.Builder
   195  				expVarMetrics.Map().Do(func(kv expvar.KeyValue) {
   196  					switch v := kv.Value.(type) {
   197  					case expvar.Func:
   198  						// skip
   199  					case *expvar.Map:
   200  						v.Do(func(kv2 expvar.KeyValue) {
   201  							fmt.Fprintf(&buf, "%s.%s: %s\n", kv.Key, kv2.Key, kv2.Value)
   202  						})
   203  					default:
   204  						fmt.Fprintf(&buf, "%s: %s\n", kv.Key, kv.Value)
   205  					}
   206  				})
   207  				return buf.String(), "", nil
   208  			}, nil
   209  		},
   210  	)
   211  
   212  	require.NoError(t, err, "ScriptCommands")
   213  	maps.Insert(cmds, maps.All(script.DefaultCmds()))
   214  
   215  	t.Cleanup(func() {
   216  		assert.NoError(t, hive.Stop(log, context.TODO()))
   217  	})
   218  
   219  	return &script.Engine{
   220  		Cmds: cmds,
   221  	}
   222  }
   223  
   224  func TestScript(t *testing.T) {
   225  	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   226  	t.Cleanup(cancel)
   227  	scripttest.Test(t,
   228  		ctx, func() *script.Engine {
   229  			return newScriptTest(t)
   230  		}, []string{}, "testdata/*.txtar")
   231  }
   232  
   233  type testObject struct {
   234  	ID      uint64
   235  	Faulty  bool
   236  	Updates int
   237  	Status  reconciler.Status
   238  }
   239  
   240  var idIndex = statedb.Index[*testObject, uint64]{
   241  	Name: "id",
   242  	FromObject: func(t *testObject) index.KeySet {
   243  		return index.NewKeySet(index.Uint64(t.ID))
   244  	},
   245  	FromKey: index.Uint64,
   246  	Unique:  true,
   247  }
   248  
   249  func (t *testObject) GetStatus() reconciler.Status {
   250  	return t.Status
   251  }
   252  
   253  func (t *testObject) SetStatus(status reconciler.Status) *testObject {
   254  	t.Status = status
   255  	return t
   256  }
   257  
   258  func (t *testObject) Clone() *testObject {
   259  	t2 := *t
   260  	return &t2
   261  }
   262  
   263  func (t *testObject) TableHeader() []string {
   264  	return []string{
   265  		"ID",
   266  		"Faulty",
   267  		"StatusKind",
   268  		"StatusError",
   269  	}
   270  }
   271  
   272  func (t *testObject) TableRow() []string {
   273  	return []string{
   274  		strconv.FormatUint(t.ID, 10),
   275  		strconv.FormatBool(t.Faulty),
   276  		string(t.Status.Kind),
   277  		t.Status.Error,
   278  	}
   279  }
   280  
   281  type opHistory struct {
   282  	mu      sync.Mutex
   283  	history []opHistoryItem
   284  }
   285  
   286  type opHistoryItem = string
   287  
   288  func opUpdate(id uint64) opHistoryItem {
   289  	return opHistoryItem(fmt.Sprintf("update(%d)", id))
   290  }
   291  func opUpdateRefresh(id uint64) opHistoryItem {
   292  	return opHistoryItem(fmt.Sprintf("update-refresh(%d)", id))
   293  }
   294  func opDelete(id uint64) opHistoryItem {
   295  	return opHistoryItem(fmt.Sprintf("delete(%d)", id))
   296  }
   297  func opPrune(numDesiredObjects int) opHistoryItem {
   298  	return opHistoryItem(fmt.Sprintf("prune(n=%d)", numDesiredObjects))
   299  }
   300  func opFail(item opHistoryItem) opHistoryItem {
   301  	return item + " fail"
   302  }
   303  
   304  func (o *opHistory) add(item opHistoryItem) {
   305  	o.mu.Lock()
   306  	o.history = append(o.history, item)
   307  	o.mu.Unlock()
   308  }
   309  
   310  func (o *opHistory) take(n int) []opHistoryItem {
   311  	o.mu.Lock()
   312  	defer o.mu.Unlock()
   313  
   314  	out := []opHistoryItem{}
   315  	for n > 0 {
   316  		idx := len(o.history) - n
   317  		if idx >= 0 {
   318  			out = append(out, o.history[idx])
   319  		}
   320  		n--
   321  	}
   322  	return out
   323  }
   324  
   325  type intMap struct {
   326  	sync.Map
   327  }
   328  
   329  func (m *intMap) incr(key uint64) {
   330  	if n, ok := m.Load(key); ok {
   331  		m.Store(key, n.(int)+1)
   332  	} else {
   333  		m.Store(key, 1)
   334  	}
   335  }
   336  
   337  type mockOps struct {
   338  	history opHistory
   339  	faulty  atomic.Bool
   340  	updates intMap
   341  }
   342  
   343  // DeleteBatch implements recogciler.BatchOperations.
   344  func (mt *mockOps) DeleteBatch(ctx context.Context, txn statedb.ReadTxn, batch []reconciler.BatchEntry[*testObject]) {
   345  	for i := range batch {
   346  		batch[i].Result = mt.Delete(ctx, txn, batch[i].Object)
   347  	}
   348  }
   349  
   350  // UpdateBatch implements reconciler.BatchOperations.
   351  func (mt *mockOps) UpdateBatch(ctx context.Context, txn statedb.ReadTxn, batch []reconciler.BatchEntry[*testObject]) {
   352  	for i := range batch {
   353  		batch[i].Result = mt.Update(ctx, txn, batch[i].Object)
   354  	}
   355  }
   356  
   357  // Delete implements reconciler.Operations.
   358  func (mt *mockOps) Delete(ctx context.Context, txn statedb.ReadTxn, obj *testObject) error {
   359  	if mt.faulty.Load() || obj.Faulty {
   360  		mt.history.add(opFail(opDelete(obj.ID)))
   361  		return errors.New("delete fail")
   362  	}
   363  	mt.history.add(opDelete(obj.ID))
   364  
   365  	return nil
   366  }
   367  
   368  // Prune implements reconciler.Operations.
   369  func (mt *mockOps) Prune(ctx context.Context, txn statedb.ReadTxn, objects iter.Seq2[*testObject, statedb.Revision]) error {
   370  	objs := statedb.Collect(objects)
   371  	if mt.faulty.Load() {
   372  		mt.history.add(opFail(opPrune(len(objs))))
   373  		return errors.New("prune fail")
   374  	}
   375  	mt.history.add(opPrune(len(objs)))
   376  	return nil
   377  }
   378  
   379  // Update implements reconciler.Operations.
   380  func (mt *mockOps) Update(ctx context.Context, txn statedb.ReadTxn, obj *testObject) error {
   381  	mt.updates.incr(obj.ID)
   382  
   383  	op := opUpdate(obj.ID)
   384  	if obj.Status.Kind == reconciler.StatusKindRefreshing {
   385  		op = opUpdateRefresh(obj.ID)
   386  	}
   387  	if mt.faulty.Load() || obj.Faulty {
   388  		mt.history.add(opFail(op))
   389  		return errors.New("update fail")
   390  	}
   391  	mt.history.add(op)
   392  	obj.Updates += 1
   393  
   394  	return nil
   395  }
   396  
   397  var _ reconciler.Operations[*testObject] = &mockOps{}
   398  var _ reconciler.BatchOperations[*testObject] = &mockOps{}