janitor_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. package kubemodel
  2. import (
  3. "fmt"
  4. "testing"
  5. "time"
  6. "github.com/opencost/opencost/core/pkg/exporter/pathing"
  7. "github.com/opencost/opencost/core/pkg/opencost"
  8. "github.com/opencost/opencost/core/pkg/storage"
  9. "github.com/opencost/opencost/core/pkg/util/timeutil"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // storeFile writes a single zero-byte placeholder at the path the janitor would
  13. // find for a given resolution and window start time.
  14. func storeFile(t *testing.T, store storage.Storage, appName, clusterID string, res time.Duration, start time.Time) string {
  15. t.Helper()
  16. resStr := timeutil.FormatStoreResolution(res)
  17. f, err := pathing.NewKubeModelStoragePathFormatter(appName, clusterID, resStr)
  18. require.NoError(t, err)
  19. w := opencost.NewClosedWindow(start, start.Add(res))
  20. p := f.ToFullPath("", w, "bingen")
  21. require.NoError(t, store.Write(p, []byte("data")))
  22. return p
  23. }
  24. // fileExists reports whether path p is present in store.
  25. func fileExists(t *testing.T, store storage.Storage, p string) bool {
  26. t.Helper()
  27. ok, err := store.Exists(p)
  28. require.NoError(t, err)
  29. return ok
  30. }
  31. // --- parseKubeModelFileTimestamp ---
  32. func TestParseKubeModelFileTimestamp(t *testing.T) {
  33. cases := []struct {
  34. name string
  35. input string
  36. want time.Time
  37. wantErr bool
  38. }{
  39. {
  40. name: "bare timestamp with extension",
  41. input: "20240115103000.bingen",
  42. want: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
  43. },
  44. {
  45. name: "timestamp without extension",
  46. input: "20240115103000",
  47. want: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
  48. },
  49. {
  50. name: "completely invalid name",
  51. input: "notadate.bingen",
  52. wantErr: true,
  53. },
  54. {
  55. name: "empty string",
  56. input: "",
  57. wantErr: true,
  58. },
  59. }
  60. for _, tc := range cases {
  61. t.Run(tc.name, func(t *testing.T) {
  62. got, err := parseKubeModelFileTimestamp(tc.input)
  63. if tc.wantErr {
  64. require.Error(t, err)
  65. return
  66. }
  67. require.NoError(t, err)
  68. require.Equal(t, tc.want, got)
  69. })
  70. }
  71. }
  72. // --- retentionFor ---
  73. func TestRetentionFor(t *testing.T) {
  74. cases := []struct {
  75. name string
  76. res time.Duration
  77. want time.Duration
  78. }{
  79. {"1d resolution uses day retention", timeutil.Day, time.Duration(janitorDefault1dRetention) * timeutil.Day},
  80. {"1h resolution uses hour retention", time.Hour, time.Duration(janitorDefault1hRetention) * time.Hour},
  81. {"10m resolution uses 10m retention", 10 * time.Minute, time.Duration(janitorDefault10mRetention) * 10 * time.Minute},
  82. }
  83. for _, tc := range cases {
  84. t.Run(tc.name, func(t *testing.T) {
  85. got := retentionFor(tc.res)
  86. require.Equal(t, tc.want, got)
  87. })
  88. }
  89. }
  90. // --- pruneResolution / cleanDay ---
  91. func TestJanitor_PrunesExpiredFiles(t *testing.T) {
  92. store := storage.NewMemoryStorage()
  93. j := NewJanitor(store, testAppName, testClusterID, []time.Duration{time.Hour})
  94. now := time.Now().UTC().Truncate(time.Hour)
  95. cutoff := now.Add(-3 * time.Hour)
  96. // Two files before the cutoff — should be removed.
  97. expiredPath1 := storeFile(t, store, testAppName, testClusterID, time.Hour, cutoff.Add(-2*time.Hour))
  98. expiredPath2 := storeFile(t, store, testAppName, testClusterID, time.Hour, cutoff.Add(-time.Hour))
  99. // One file after the cutoff — should be kept.
  100. keptPath := storeFile(t, store, testAppName, testClusterID, time.Hour, cutoff.Add(time.Hour))
  101. resStr := timeutil.FormatStoreResolution(time.Hour)
  102. baseDir := fmt.Sprintf("%s/%s/kubemodel/%s", testAppName, testClusterID, resStr)
  103. j.pruneResolution(baseDir, cutoff)
  104. require.False(t, fileExists(t, store, expiredPath1), "expired file 1 should be removed")
  105. require.False(t, fileExists(t, store, expiredPath2), "expired file 2 should be removed")
  106. require.True(t, fileExists(t, store, keptPath), "recent file should be kept")
  107. }
  108. func TestJanitor_KeepsFilesExactlyAtCutoff(t *testing.T) {
  109. store := storage.NewMemoryStorage()
  110. j := NewJanitor(store, testAppName, testClusterID, []time.Duration{time.Hour})
  111. now := time.Now().UTC().Truncate(time.Hour)
  112. cutoff := now.Add(-3 * time.Hour)
  113. // File whose timestamp equals the cutoff exactly — should be kept (not Before).
  114. atCutoffPath := storeFile(t, store, testAppName, testClusterID, time.Hour, cutoff)
  115. resStr := timeutil.FormatStoreResolution(time.Hour)
  116. baseDir := fmt.Sprintf("%s/%s/kubemodel/%s", testAppName, testClusterID, resStr)
  117. j.pruneResolution(baseDir, cutoff)
  118. require.True(t, fileExists(t, store, atCutoffPath), "file at cutoff boundary should not be removed")
  119. }
  120. func TestJanitor_EmptyStorageIsNoop(t *testing.T) {
  121. store := storage.NewMemoryStorage()
  122. j := NewJanitor(store, testAppName, testClusterID, []time.Duration{time.Hour})
  123. resStr := timeutil.FormatStoreResolution(time.Hour)
  124. baseDir := fmt.Sprintf("%s/%s/kubemodel/%s", testAppName, testClusterID, resStr)
  125. // Should not panic or error on an empty store.
  126. require.NotPanics(t, func() {
  127. j.pruneResolution(baseDir, time.Now().UTC())
  128. })
  129. }
  130. func TestJanitor_FilesWithUnparsableNamesAreSkipped(t *testing.T) {
  131. store := storage.NewMemoryStorage()
  132. j := NewJanitor(store, testAppName, testClusterID, []time.Duration{time.Hour})
  133. now := time.Now().UTC().Truncate(time.Hour)
  134. cutoff := now.Add(-time.Hour)
  135. // Write a file at a valid day-dir path but with an unparsable name — janitor
  136. // should skip it rather than panic or remove it.
  137. resStr := timeutil.FormatStoreResolution(time.Hour)
  138. badPath := fmt.Sprintf("%s/%s/kubemodel/%s/%s/garbage.bingen",
  139. testAppName, testClusterID, resStr,
  140. cutoff.Add(-24*time.Hour).Format("2006/01/02"),
  141. )
  142. require.NoError(t, store.Write(badPath, []byte("data")))
  143. baseDir := fmt.Sprintf("%s/%s/kubemodel/%s", testAppName, testClusterID, resStr)
  144. require.NotPanics(t, func() {
  145. j.pruneResolution(baseDir, cutoff)
  146. })
  147. // File with bad name must not have been deleted.
  148. require.True(t, fileExists(t, store, badPath))
  149. }
  150. // --- Start / Stop idempotency ---
  151. func TestJanitor_StartIsIdempotent(t *testing.T) {
  152. store := storage.NewMemoryStorage()
  153. j := NewJanitor(store, testAppName, testClusterID, []time.Duration{time.Hour})
  154. j.Start(time.Hour)
  155. j.Start(time.Hour) // second call must not panic or spawn a second goroutine
  156. j.Stop()
  157. }
  158. func TestJanitor_StopIsIdempotent(t *testing.T) {
  159. store := storage.NewMemoryStorage()
  160. j := NewJanitor(store, testAppName, testClusterID, []time.Duration{time.Hour})
  161. j.Start(time.Hour)
  162. j.Stop()
  163. j.Stop() // second stop must not panic (closing a closed channel would panic)
  164. }