querier_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. package kubemodel
  2. import (
  3. "testing"
  4. "time"
  5. "github.com/opencost/opencost/core/pkg/exporter"
  6. "github.com/opencost/opencost/core/pkg/exporter/pathing"
  7. coremodel "github.com/opencost/opencost/core/pkg/model/kubemodel"
  8. "github.com/opencost/opencost/core/pkg/opencost"
  9. "github.com/opencost/opencost/core/pkg/storage"
  10. "github.com/opencost/opencost/core/pkg/util/timeutil"
  11. "github.com/stretchr/testify/require"
  12. )
  13. const (
  14. testAppName = "test-app"
  15. testClusterID = "test-cluster"
  16. )
  17. // storeKMS marshals kms and writes it to store at the path the querier expects for the given window.
  18. func storeKMS(t *testing.T, store storage.Storage, appName, clusterID string, res time.Duration, w opencost.Window) {
  19. t.Helper()
  20. resStr := timeutil.FormatStoreResolution(res)
  21. f, err := pathing.NewKubeModelStoragePathFormatter(appName, clusterID, resStr)
  22. require.NoError(t, err)
  23. p := f.ToFullPath("", w, exporter.BingenExt)
  24. kms := coremodel.NewMockKubeModelSet(*w.Start(), *w.End())
  25. data, err := kms.MarshalBinary()
  26. require.NoError(t, err)
  27. require.NoError(t, store.Write(p, data))
  28. }
  29. // --- snapResolution ---
  30. func TestSnapResolution(t *testing.T) {
  31. cases := []struct {
  32. name string
  33. window opencost.Window
  34. expected time.Duration
  35. }{
  36. {
  37. name: "1h window snaps to 1h",
  38. window: opencost.NewClosedWindow(time.Time{}, time.Time{}.Add(time.Hour)),
  39. expected: time.Hour,
  40. },
  41. {
  42. name: "3h window snaps to 1h",
  43. window: opencost.NewClosedWindow(time.Time{}, time.Time{}.Add(3*time.Hour)),
  44. expected: time.Hour,
  45. },
  46. {
  47. name: "12h window snaps to 1h",
  48. window: opencost.NewClosedWindow(time.Time{}, time.Time{}.Add(12*time.Hour)),
  49. expected: time.Hour,
  50. },
  51. {
  52. name: "24h window snaps to 1d",
  53. window: opencost.NewClosedWindow(time.Time{}, time.Time{}.Add(timeutil.Day)),
  54. expected: timeutil.Day,
  55. },
  56. {
  57. name: "48h window snaps to 1d",
  58. window: opencost.NewClosedWindow(time.Time{}, time.Time{}.Add(2*timeutil.Day)),
  59. expected: timeutil.Day,
  60. },
  61. {
  62. name: "90m window (not evenly divisible) falls back to 1h",
  63. window: opencost.NewClosedWindow(time.Time{}, time.Time{}.Add(90*time.Minute)),
  64. expected: time.Hour,
  65. },
  66. }
  67. for _, tc := range cases {
  68. t.Run(tc.name, func(t *testing.T) {
  69. got := snapResolution(tc.window)
  70. if got != tc.expected {
  71. t.Errorf("snapResolution(%v) = %v, want %v", tc.window.Duration(), got, tc.expected)
  72. }
  73. })
  74. }
  75. }
  76. // --- Querier.Query ---
  77. func TestQuerier_Query_RejectsOpenWindow(t *testing.T) {
  78. q := NewQuerier(testAppName, testClusterID, storage.NewMemoryStorage())
  79. end := time.Now().UTC()
  80. _, err := q.Query(opencost.NewWindow(nil, &end))
  81. require.Error(t, err, "nil start should be rejected")
  82. start := time.Now().UTC()
  83. _, err = q.Query(opencost.NewWindow(&start, nil))
  84. require.Error(t, err, "nil end should be rejected")
  85. }
  86. func TestQuerier_Query_EmptyStorageReturnsNoResults(t *testing.T) {
  87. q := NewQuerier(testAppName, testClusterID, storage.NewMemoryStorage())
  88. start := time.Now().UTC().Truncate(time.Hour)
  89. results, err := q.Query(opencost.NewClosedWindow(start, start.Add(3*time.Hour)))
  90. require.NoError(t, err)
  91. require.Empty(t, results)
  92. }
  93. func TestQuerier_Query_SingleHourlyWindow(t *testing.T) {
  94. store := storage.NewMemoryStorage()
  95. q := NewQuerier(testAppName, testClusterID, store)
  96. start := time.Now().UTC().Truncate(time.Hour)
  97. window := opencost.NewClosedWindow(start, start.Add(time.Hour))
  98. storeKMS(t, store, testAppName, testClusterID, time.Hour, window)
  99. results, err := q.Query(window)
  100. require.NoError(t, err)
  101. require.Len(t, results, 1)
  102. }
  103. func TestQuerier_Query_MultipleHourlySubWindows(t *testing.T) {
  104. store := storage.NewMemoryStorage()
  105. q := NewQuerier(testAppName, testClusterID, store)
  106. start := time.Now().UTC().Truncate(time.Hour)
  107. for i := range 3 {
  108. w := opencost.NewClosedWindow(start.Add(time.Duration(i)*time.Hour), start.Add(time.Duration(i+1)*time.Hour))
  109. storeKMS(t, store, testAppName, testClusterID, time.Hour, w)
  110. }
  111. results, err := q.Query(opencost.NewClosedWindow(start, start.Add(3*time.Hour)))
  112. require.NoError(t, err)
  113. require.Len(t, results, 3)
  114. }
  115. func TestQuerier_Query_SkipsMissingSubWindows(t *testing.T) {
  116. store := storage.NewMemoryStorage()
  117. q := NewQuerier(testAppName, testClusterID, store)
  118. start := time.Now().UTC().Truncate(time.Hour)
  119. // Write first and third sub-windows; leave the middle missing.
  120. storeKMS(t, store, testAppName, testClusterID, time.Hour, opencost.NewClosedWindow(start, start.Add(time.Hour)))
  121. storeKMS(t, store, testAppName, testClusterID, time.Hour, opencost.NewClosedWindow(start.Add(2*time.Hour), start.Add(3*time.Hour)))
  122. results, err := q.Query(opencost.NewClosedWindow(start, start.Add(3*time.Hour)))
  123. require.NoError(t, err)
  124. require.Len(t, results, 2)
  125. }
  126. func TestQuerier_Query_DailyResolutionForFullDayWindow(t *testing.T) {
  127. store := storage.NewMemoryStorage()
  128. q := NewQuerier(testAppName, testClusterID, store)
  129. start := time.Now().UTC().Truncate(timeutil.Day)
  130. window := opencost.NewClosedWindow(start, start.Add(timeutil.Day))
  131. storeKMS(t, store, testAppName, testClusterID, timeutil.Day, window)
  132. results, err := q.Query(window)
  133. require.NoError(t, err)
  134. require.Len(t, results, 1)
  135. }
  136. func TestQuerier_Query_TruncatesWindowToResolution(t *testing.T) {
  137. store := storage.NewMemoryStorage()
  138. q := NewQuerier(testAppName, testClusterID, store)
  139. // Aligned hour boundary for the one sub-window we expect to be queried.
  140. alignedStart := time.Now().UTC().Truncate(time.Hour)
  141. alignedWindow := opencost.NewClosedWindow(alignedStart, alignedStart.Add(time.Hour))
  142. storeKMS(t, store, testAppName, testClusterID, time.Hour, alignedWindow)
  143. // Query with start/end that are 15 minutes into the hour — both truncate to
  144. // the same aligned boundary, so we still get the one stored sub-window.
  145. unalignedStart := alignedStart.Add(15 * time.Minute)
  146. unalignedEnd := alignedStart.Add(time.Hour + 15*time.Minute)
  147. results, err := q.Query(opencost.NewClosedWindow(unalignedStart, unalignedEnd))
  148. require.NoError(t, err)
  149. require.Len(t, results, 1)
  150. }
  151. func TestQuerier_Query_CorruptDataReturnsError(t *testing.T) {
  152. store := storage.NewMemoryStorage()
  153. q := NewQuerier(testAppName, testClusterID, store)
  154. start := time.Now().UTC().Truncate(time.Hour)
  155. window := opencost.NewClosedWindow(start, start.Add(time.Hour))
  156. resStr := timeutil.FormatStoreResolution(time.Hour)
  157. f, err := pathing.NewKubeModelStoragePathFormatter(testAppName, testClusterID, resStr)
  158. require.NoError(t, err)
  159. p := f.ToFullPath("", window, exporter.BingenExt)
  160. require.NoError(t, store.Write(p, []byte("not valid binary data")))
  161. _, err = q.Query(window)
  162. require.Error(t, err)
  163. }