window.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. package kubecost
  2. import (
  3. "bytes"
  4. "fmt"
  5. "math"
  6. "regexp"
  7. "strconv"
  8. "time"
  9. "github.com/kubecost/cost-model/pkg/util"
  10. )
  11. const (
  12. minutesPerDay = 60 * 24
  13. minutesPerHour = 60
  14. hoursPerDay = 24
  15. )
  16. // RoundBack rounds the given time back to a multiple of the given resolution
  17. // in the given time's timezone.
  18. // e.g. 2020-01-01T12:37:48-0700, 24h = 2020-01-01T00:00:00-0700
  19. func RoundBack(t time.Time, resolution time.Duration) time.Time {
  20. _, offSec := t.Zone()
  21. return t.Add(time.Duration(offSec) * time.Second).Truncate(resolution).Add(-time.Duration(offSec) * time.Second)
  22. }
  23. // RoundForward rounds the given time forward to a multiple of the given resolution
  24. // in the given time's timezone.
  25. // e.g. 2020-01-01T12:37:48-0700, 24h = 2020-01-02T00:00:00-0700
  26. func RoundForward(t time.Time, resolution time.Duration) time.Time {
  27. back := RoundBack(t, resolution)
  28. if back.Equal(t) {
  29. // The given time is exactly a multiple of the given resolution
  30. return t
  31. }
  32. return back.Add(resolution)
  33. }
  34. // Window defines a period of time with a start and an end. If either start or
  35. // end are nil it indicates an open time period.
  36. type Window struct {
  37. start *time.Time
  38. end *time.Time
  39. }
  40. // NewWindow creates and returns a new Window instance from the given times
  41. func NewWindow(start, end *time.Time) Window {
  42. return Window{
  43. start: start,
  44. end: end,
  45. }
  46. }
  47. // NewClosedWindow creates and returns a new Window instance from the given
  48. // times, which cannot be nil, so they are value types.
  49. func NewClosedWindow(start, end time.Time) Window {
  50. return Window{
  51. start: &start,
  52. end: &end,
  53. }
  54. }
  55. // ParseWindowUTC attempts to parse the given string into a valid Window. It
  56. // accepts several formats, returning an error if the given string does not
  57. // match one of the following:
  58. // - named intervals: "today", "yesterday", "week", "month", "lastweek", "lastmonth"
  59. // - durations: "24h", "7d", etc.
  60. // - date ranges: "2020-04-01T00:00:00Z,2020-04-03T00:00:00Z", etc.
  61. // - timestamp ranges: "1586822400,1586908800", etc.
  62. func ParseWindowUTC(window string) (Window, error) {
  63. return parseWindow(window, time.Now().UTC())
  64. }
  65. // ParseWindowWithOffsetString parses the given window string within the context of
  66. // the timezone defined by the UTC offset string of format -07:00, +01:30, etc.
  67. func ParseWindowWithOffsetString(window string, offset string) (Window, error) {
  68. if offset == "UTC" || offset == "" {
  69. return ParseWindowUTC(window)
  70. }
  71. regex := regexp.MustCompile(`^(\+|-)(\d\d):(\d\d)$`)
  72. match := regex.FindStringSubmatch(offset)
  73. if match == nil {
  74. return Window{}, fmt.Errorf("illegal UTC offset: '%s'; should be of form '-07:00'", offset)
  75. }
  76. sig := 1
  77. if match[1] == "-" {
  78. sig = -1
  79. }
  80. hrs64, _ := strconv.ParseInt(match[2], 10, 64)
  81. hrs := sig * int(hrs64)
  82. mins64, _ := strconv.ParseInt(match[3], 10, 64)
  83. mins := sig * int(mins64)
  84. loc := time.FixedZone(fmt.Sprintf("UTC%s", offset), (hrs*60*60)+(mins*60))
  85. now := time.Now().In(loc)
  86. return parseWindow(window, now)
  87. }
  88. // ParseWindowWithOffset parses the given window string within the context of
  89. // the timezone defined by the UTC offset.
  90. func ParseWindowWithOffset(window string, offset time.Duration) (Window, error) {
  91. loc := time.FixedZone("", int(offset.Seconds()))
  92. now := time.Now().In(loc)
  93. return parseWindow(window, now)
  94. }
  95. // parseWindow generalizes the parsing of window strings, relative to a given
  96. // moment in time, defined as "now".
  97. func parseWindow(window string, now time.Time) (Window, error) {
  98. // compute UTC offset in terms of minutes
  99. offHr := now.UTC().Hour() - now.Hour()
  100. offMin := (now.UTC().Minute() - now.Minute()) + (offHr * 60)
  101. offset := time.Duration(offMin) * time.Minute
  102. if window == "today" {
  103. start := now
  104. start = start.Truncate(time.Hour * 24)
  105. start = start.Add(offset)
  106. end := start.Add(time.Hour * 24)
  107. return NewWindow(&start, &end), nil
  108. }
  109. if window == "yesterday" {
  110. start := now
  111. start = start.Truncate(time.Hour * 24)
  112. start = start.Add(offset)
  113. start = start.Add(time.Hour * -24)
  114. end := start.Add(time.Hour * 24)
  115. return NewWindow(&start, &end), nil
  116. }
  117. if window == "week" {
  118. // now
  119. start := now
  120. // 00:00 today, accounting for timezone offset
  121. start = start.Truncate(time.Hour * 24)
  122. start = start.Add(offset)
  123. // 00:00 Sunday of the current week
  124. start = start.Add(-24 * time.Hour * time.Duration(start.Weekday()))
  125. end := now
  126. return NewWindow(&start, &end), nil
  127. }
  128. if window == "lastweek" {
  129. // now
  130. start := now
  131. // 00:00 today, accounting for timezone offset
  132. start = start.Truncate(time.Hour * 24)
  133. start = start.Add(offset)
  134. // 00:00 Sunday of last week
  135. start = start.Add(-24 * time.Hour * time.Duration(start.Weekday()+7))
  136. end := start.Add(7 * 24 * time.Hour)
  137. return NewWindow(&start, &end), nil
  138. }
  139. if window == "month" {
  140. // now
  141. start := now
  142. // 00:00 today, accounting for timezone offset
  143. start = start.Truncate(time.Hour * 24)
  144. start = start.Add(offset)
  145. // 00:00 1st of this month
  146. start = start.Add(-24 * time.Hour * time.Duration(start.Day()-1))
  147. end := now
  148. return NewWindow(&start, &end), nil
  149. }
  150. if window == "month" {
  151. // now
  152. start := now
  153. // 00:00 today, accounting for timezone offset
  154. start = start.Truncate(time.Hour * 24)
  155. start = start.Add(offset)
  156. // 00:00 1st of this month
  157. start = start.Add(-24 * time.Hour * time.Duration(start.Day()-1))
  158. end := now
  159. return NewWindow(&start, &end), nil
  160. }
  161. if window == "lastmonth" {
  162. // now
  163. end := now
  164. // 00:00 today, accounting for timezone offset
  165. end = end.Truncate(time.Hour * 24)
  166. end = end.Add(offset)
  167. // 00:00 1st of this month
  168. end = end.Add(-24 * time.Hour * time.Duration(end.Day()-1))
  169. // 00:00 last day of last month
  170. start := end.Add(-24 * time.Hour)
  171. // 00:00 1st of last month
  172. start = start.Add(-24 * time.Hour * time.Duration(start.Day()-1))
  173. return NewWindow(&start, &end), nil
  174. }
  175. // Match duration strings; e.g. "45m", "24h", "7d"
  176. regex := regexp.MustCompile(`^(\d+)(m|h|d)$`)
  177. match := regex.FindStringSubmatch(window)
  178. if match != nil {
  179. dur := time.Minute
  180. if match[2] == "h" {
  181. dur = time.Hour
  182. }
  183. if match[2] == "d" {
  184. dur = 24 * time.Hour
  185. }
  186. num, _ := strconv.ParseInt(match[1], 10, 64)
  187. end := now
  188. start := end.Add(-time.Duration(num) * dur)
  189. return NewWindow(&start, &end), nil
  190. }
  191. // Match duration strings with offset; e.g. "45m offset 15m", etc.
  192. regex = regexp.MustCompile(`^(\d+)(m|h|d) offset (\d+)(m|h|d)$`)
  193. match = regex.FindStringSubmatch(window)
  194. if match != nil {
  195. end := now
  196. offUnit := time.Minute
  197. if match[4] == "h" {
  198. offUnit = time.Hour
  199. }
  200. if match[4] == "d" {
  201. offUnit = 24 * time.Hour
  202. }
  203. offNum, _ := strconv.ParseInt(match[3], 10, 64)
  204. end = end.Add(-time.Duration(offNum) * offUnit)
  205. durUnit := time.Minute
  206. if match[2] == "h" {
  207. durUnit = time.Hour
  208. }
  209. if match[2] == "d" {
  210. durUnit = 24 * time.Hour
  211. }
  212. durNum, _ := strconv.ParseInt(match[1], 10, 64)
  213. start := end.Add(-time.Duration(durNum) * durUnit)
  214. return NewWindow(&start, &end), nil
  215. }
  216. // Match timestamp pairs, e.g. "1586822400,1586908800" or "1586822400-1586908800"
  217. regex = regexp.MustCompile(`^(\d+)[,|-](\d+)$`)
  218. match = regex.FindStringSubmatch(window)
  219. if match != nil {
  220. s, _ := strconv.ParseInt(match[1], 10, 64)
  221. e, _ := strconv.ParseInt(match[2], 10, 64)
  222. start := time.Unix(s, 0)
  223. end := time.Unix(e, 0)
  224. return NewWindow(&start, &end), nil
  225. }
  226. // Match RFC3339 pairs, e.g. "2020-04-01T00:00:00Z,2020-04-03T00:00:00Z"
  227. rfc3339 := `\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ`
  228. regex = regexp.MustCompile(fmt.Sprintf(`(%s),(%s)`, rfc3339, rfc3339))
  229. match = regex.FindStringSubmatch(window)
  230. if match != nil {
  231. start, _ := time.Parse(time.RFC3339, match[1])
  232. end, _ := time.Parse(time.RFC3339, match[2])
  233. return NewWindow(&start, &end), nil
  234. }
  235. return Window{nil, nil}, fmt.Errorf("illegal window: %s", window)
  236. }
  237. // ApproximatelyEqual returns true if the start and end times of the two windows,
  238. // respectively, are within the given threshold of each other.
  239. func (w Window) ApproximatelyEqual(that Window, threshold time.Duration) bool {
  240. return approxEqual(w.start, that.start, threshold) && approxEqual(w.end, that.end, threshold)
  241. }
  242. func approxEqual(x *time.Time, y *time.Time, threshold time.Duration) bool {
  243. // both times are nil, so they are equal
  244. if x == nil && y == nil {
  245. return true
  246. }
  247. // one time is nil, but the other is not, so they are not equal
  248. if x == nil || y == nil {
  249. return false
  250. }
  251. // neither time is nil, so they are approximately close if their times are
  252. // within the given threshold
  253. delta := math.Abs((*x).Sub(*y).Seconds())
  254. return delta < threshold.Seconds()
  255. }
  256. func (w Window) Clone() Window {
  257. var start, end *time.Time
  258. var s, e time.Time
  259. if w.start != nil {
  260. s = *w.start
  261. start = &s
  262. }
  263. if w.end != nil {
  264. e = *w.end
  265. end = &e
  266. }
  267. return NewWindow(start, end)
  268. }
  269. func (w Window) Contains(t time.Time) bool {
  270. if w.start != nil && t.Before(*w.start) {
  271. return false
  272. }
  273. if w.end != nil && t.After(*w.end) {
  274. return false
  275. }
  276. return true
  277. }
  278. func (w Window) Duration() time.Duration {
  279. if w.IsOpen() {
  280. // TODO test
  281. return time.Duration(math.Inf(1.0))
  282. }
  283. return w.end.Sub(*w.start)
  284. }
  285. func (w Window) End() *time.Time {
  286. return w.end
  287. }
  288. func (w Window) Equal(that Window) bool {
  289. if w.start != nil && that.start != nil && !w.start.Equal(*that.start) {
  290. // starts are not nil, but not equal
  291. return false
  292. }
  293. if w.end != nil && that.end != nil && !w.end.Equal(*that.end) {
  294. // ends are not nil, but not equal
  295. return false
  296. }
  297. if (w.start == nil && that.start != nil) || (w.start != nil && that.start == nil) {
  298. // one start is nil, the other is not
  299. return false
  300. }
  301. if (w.end == nil && that.end != nil) || (w.end != nil && that.end == nil) {
  302. // one end is nil, the other is not
  303. return false
  304. }
  305. // either both starts are nil, or they match; likewise for the ends
  306. return true
  307. }
  308. func (w Window) ExpandStart(start time.Time) Window {
  309. if w.start == nil || start.Before(*w.start) {
  310. w.start = &start
  311. }
  312. return w
  313. }
  314. func (w Window) ExpandEnd(end time.Time) Window {
  315. if w.end == nil || end.After(*w.end) {
  316. w.end = &end
  317. }
  318. return w
  319. }
  320. func (w Window) Expand(that Window) Window {
  321. return w.ExpandStart(*that.start).ExpandEnd(*that.end)
  322. }
  323. func (w Window) Hours() float64 {
  324. if w.IsOpen() {
  325. return math.Inf(1)
  326. }
  327. return w.end.Sub(*w.start).Hours()
  328. }
  329. func (w Window) IsEmpty() bool {
  330. return !w.IsOpen() && w.end.Equal(*w.Start())
  331. }
  332. func (w Window) IsNegative() bool {
  333. return !w.IsOpen() && w.end.Before(*w.Start())
  334. }
  335. func (w Window) IsOpen() bool {
  336. return w.start == nil || w.end == nil
  337. }
  338. func (w Window) MarshalJSON() ([]byte, error) {
  339. buffer := bytes.NewBufferString("{")
  340. buffer.WriteString(fmt.Sprintf("\"start\":\"%s\",", w.start.Format("2006-01-02T15:04:05-0700")))
  341. buffer.WriteString(fmt.Sprintf("\"end\":\"%s\"", w.end.Format("2006-01-02T15:04:05-0700")))
  342. buffer.WriteString("}")
  343. return buffer.Bytes(), nil
  344. }
  345. func (w Window) Minutes() float64 {
  346. if w.IsOpen() {
  347. return math.Inf(1)
  348. }
  349. return w.end.Sub(*w.start).Minutes()
  350. }
  351. func (w Window) Set(start, end *time.Time) {
  352. w.start = start
  353. w.end = end
  354. }
  355. // Shift adds the given duration to both the start and end times of the window
  356. func (w Window) Shift(dur time.Duration) Window {
  357. if w.start != nil {
  358. s := w.start.Add(dur)
  359. w.start = &s
  360. }
  361. if w.end != nil {
  362. e := w.end.Add(dur)
  363. w.end = &e
  364. }
  365. return w
  366. }
  367. func (w Window) Start() *time.Time {
  368. return w.start
  369. }
  370. func (w Window) String() string {
  371. if w.start == nil && w.end == nil {
  372. return "[nil, nil)"
  373. }
  374. if w.start == nil {
  375. return fmt.Sprintf("[nil, %s)", w.end.Format("2006-01-02T15:04:05-0700"))
  376. }
  377. if w.end == nil {
  378. return fmt.Sprintf("[%s, nil)", w.start.Format("2006-01-02T15:04:05-0700"))
  379. }
  380. return fmt.Sprintf("[%s, %s)", w.start.Format("2006-01-02T15:04:05-0700"), w.end.Format("2006-01-02T15:04:05-0700"))
  381. }
  382. // DurationOffset returns durations representing the duration and offset of the
  383. // given window
  384. func (w Window) DurationOffset() (time.Duration, time.Duration, error) {
  385. if w.IsOpen() || w.IsNegative() {
  386. return 0, 0, fmt.Errorf("illegal window: %s", w)
  387. }
  388. duration := w.Duration()
  389. offset := time.Now().Sub(*w.End())
  390. return duration, offset, nil
  391. }
  392. // DurationOffsetStrings returns formatted, Prometheus-compatible strings representing
  393. // the duration and offset of the window in terms of days, hours, minutes, or seconds;
  394. // e.g. ("7d", "1441m", "30m", "1s", "")
  395. func (w Window) DurationOffsetStrings() (string, string) {
  396. dur, off, err := w.DurationOffset()
  397. if err != nil {
  398. return "", ""
  399. }
  400. return util.DurationOffsetStrings(dur, off)
  401. }
  402. type BoundaryError struct {
  403. Requested Window
  404. Supported Window
  405. Message string
  406. }
  407. func NewBoundaryError(req, sup Window, msg string) *BoundaryError {
  408. return &BoundaryError{
  409. Requested: req,
  410. Supported: sup,
  411. Message: msg,
  412. }
  413. }
  414. func (be *BoundaryError) Error() string {
  415. if be == nil {
  416. return "<nil>"
  417. }
  418. return fmt.Sprintf("boundary error: requested %s; supported %s: %s", be.Requested, be.Supported, be.Message)
  419. }