window.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. package kubecost
  2. import (
  3. "bytes"
  4. "fmt"
  5. "math"
  6. "regexp"
  7. "strconv"
  8. "time"
  9. "github.com/opencost/opencost/pkg/util/timeutil"
  10. "github.com/opencost/opencost/pkg/env"
  11. "github.com/opencost/opencost/pkg/thanos"
  12. )
  13. const (
  14. minutesPerDay = 60 * 24
  15. minutesPerHour = 60
  16. hoursPerDay = 24
  17. )
  18. var (
  19. durationRegex = regexp.MustCompile(`^(\d+)(m|h|d)$`)
  20. durationOffsetRegex = regexp.MustCompile(`^(\d+)(m|h|d) offset (\d+)(m|h|d)$`)
  21. offesetRegex = regexp.MustCompile(`^(\+|-)(\d\d):(\d\d)$`)
  22. rfc3339 = `\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ`
  23. rfcRegex = regexp.MustCompile(fmt.Sprintf(`(%s),(%s)`, rfc3339, rfc3339))
  24. timestampPairRegex = regexp.MustCompile(`^(\d+)[,|-](\d+)$`)
  25. )
  26. // RoundBack rounds the given time back to a multiple of the given resolution
  27. // in the given time's timezone.
  28. // e.g. 2020-01-01T12:37:48-0700, 24h = 2020-01-01T00:00:00-0700
  29. func RoundBack(t time.Time, resolution time.Duration) time.Time {
  30. _, offSec := t.Zone()
  31. return t.Add(time.Duration(offSec) * time.Second).Truncate(resolution).Add(-time.Duration(offSec) * time.Second)
  32. }
  33. // RoundForward rounds the given time forward to a multiple of the given resolution
  34. // in the given time's timezone.
  35. // e.g. 2020-01-01T12:37:48-0700, 24h = 2020-01-02T00:00:00-0700
  36. func RoundForward(t time.Time, resolution time.Duration) time.Time {
  37. back := RoundBack(t, resolution)
  38. if back.Equal(t) {
  39. // The given time is exactly a multiple of the given resolution
  40. return t
  41. }
  42. return back.Add(resolution)
  43. }
  44. // Window defines a period of time with a start and an end. If either start or
  45. // end are nil it indicates an open time period.
  46. type Window struct {
  47. start *time.Time
  48. end *time.Time
  49. }
  50. // NewWindow creates and returns a new Window instance from the given times
  51. func NewWindow(start, end *time.Time) Window {
  52. return Window{
  53. start: start,
  54. end: end,
  55. }
  56. }
  57. // NewClosedWindow creates and returns a new Window instance from the given
  58. // times, which cannot be nil, so they are value types.
  59. func NewClosedWindow(start, end time.Time) Window {
  60. return Window{
  61. start: &start,
  62. end: &end,
  63. }
  64. }
  65. // ParseWindowUTC attempts to parse the given string into a valid Window. It
  66. // accepts several formats, returning an error if the given string does not
  67. // match one of the following:
  68. // - named intervals: "today", "yesterday", "week", "month", "lastweek", "lastmonth"
  69. // - durations: "24h", "7d", etc.
  70. // - date ranges: "2020-04-01T00:00:00Z,2020-04-03T00:00:00Z", etc.
  71. // - timestamp ranges: "1586822400,1586908800", etc.
  72. func ParseWindowUTC(window string) (Window, error) {
  73. return parseWindow(window, time.Now().UTC())
  74. }
  75. // ParseWindowWithOffsetString parses the given window string within the context of
  76. // the timezone defined by the UTC offset string of format -07:00, +01:30, etc.
  77. func ParseWindowWithOffsetString(window string, offset string) (Window, error) {
  78. if offset == "UTC" || offset == "" {
  79. return ParseWindowUTC(window)
  80. }
  81. match := offesetRegex.FindStringSubmatch(offset)
  82. if match == nil {
  83. return Window{}, fmt.Errorf("illegal UTC offset: '%s'; should be of form '-07:00'", offset)
  84. }
  85. sig := 1
  86. if match[1] == "-" {
  87. sig = -1
  88. }
  89. hrs64, _ := strconv.ParseInt(match[2], 10, 64)
  90. hrs := sig * int(hrs64)
  91. mins64, _ := strconv.ParseInt(match[3], 10, 64)
  92. mins := sig * int(mins64)
  93. loc := time.FixedZone(fmt.Sprintf("UTC%s", offset), (hrs*60*60)+(mins*60))
  94. now := time.Now().In(loc)
  95. return parseWindow(window, now)
  96. }
  97. // ParseWindowWithOffset parses the given window string within the context of
  98. // the timezone defined by the UTC offset.
  99. func ParseWindowWithOffset(window string, offset time.Duration) (Window, error) {
  100. loc := time.FixedZone("", int(offset.Seconds()))
  101. now := time.Now().In(loc)
  102. return parseWindow(window, now)
  103. }
  104. // parseWindow generalizes the parsing of window strings, relative to a given
  105. // moment in time, defined as "now".
  106. func parseWindow(window string, now time.Time) (Window, error) {
  107. // compute UTC offset in terms of minutes
  108. offHr := now.UTC().Hour() - now.Hour()
  109. offMin := (now.UTC().Minute() - now.Minute()) + (offHr * 60)
  110. offset := time.Duration(offMin) * time.Minute
  111. if window == "today" {
  112. start := now
  113. start = start.Truncate(time.Hour * 24)
  114. start = start.Add(offset)
  115. end := start.Add(time.Hour * 24)
  116. return NewWindow(&start, &end), nil
  117. }
  118. if window == "yesterday" {
  119. start := now
  120. start = start.Truncate(time.Hour * 24)
  121. start = start.Add(offset)
  122. start = start.Add(time.Hour * -24)
  123. end := start.Add(time.Hour * 24)
  124. return NewWindow(&start, &end), nil
  125. }
  126. if window == "week" {
  127. // now
  128. start := now
  129. // 00:00 today, accounting for timezone offset
  130. start = start.Truncate(time.Hour * 24)
  131. start = start.Add(offset)
  132. // 00:00 Sunday of the current week
  133. start = start.Add(-24 * time.Hour * time.Duration(start.Weekday()))
  134. end := now
  135. return NewWindow(&start, &end), nil
  136. }
  137. if window == "lastweek" {
  138. // now
  139. start := now
  140. // 00:00 today, accounting for timezone offset
  141. start = start.Truncate(time.Hour * 24)
  142. start = start.Add(offset)
  143. // 00:00 Sunday of last week
  144. start = start.Add(-24 * time.Hour * time.Duration(start.Weekday()+7))
  145. end := start.Add(7 * 24 * time.Hour)
  146. return NewWindow(&start, &end), nil
  147. }
  148. if window == "month" {
  149. // now
  150. start := now
  151. // 00:00 today, accounting for timezone offset
  152. start = start.Truncate(time.Hour * 24)
  153. start = start.Add(offset)
  154. // 00:00 1st of this month
  155. start = start.Add(-24 * time.Hour * time.Duration(start.Day()-1))
  156. end := now
  157. return NewWindow(&start, &end), nil
  158. }
  159. if window == "month" {
  160. // now
  161. start := now
  162. // 00:00 today, accounting for timezone offset
  163. start = start.Truncate(time.Hour * 24)
  164. start = start.Add(offset)
  165. // 00:00 1st of this month
  166. start = start.Add(-24 * time.Hour * time.Duration(start.Day()-1))
  167. end := now
  168. return NewWindow(&start, &end), nil
  169. }
  170. if window == "lastmonth" {
  171. // now
  172. end := now
  173. // 00:00 today, accounting for timezone offset
  174. end = end.Truncate(time.Hour * 24)
  175. end = end.Add(offset)
  176. // 00:00 1st of this month
  177. end = end.Add(-24 * time.Hour * time.Duration(end.Day()-1))
  178. // 00:00 last day of last month
  179. start := end.Add(-24 * time.Hour)
  180. // 00:00 1st of last month
  181. start = start.Add(-24 * time.Hour * time.Duration(start.Day()-1))
  182. return NewWindow(&start, &end), nil
  183. }
  184. // Match duration strings; e.g. "45m", "24h", "7d"
  185. match := durationRegex.FindStringSubmatch(window)
  186. if match != nil {
  187. dur := time.Minute
  188. if match[2] == "h" {
  189. dur = time.Hour
  190. }
  191. if match[2] == "d" {
  192. dur = 24 * time.Hour
  193. }
  194. num, _ := strconv.ParseInt(match[1], 10, 64)
  195. end := now
  196. start := end.Add(-time.Duration(num) * dur)
  197. return NewWindow(&start, &end), nil
  198. }
  199. // Match duration strings with offset; e.g. "45m offset 15m", etc.
  200. match = durationOffsetRegex.FindStringSubmatch(window)
  201. if match != nil {
  202. end := now
  203. offUnit := time.Minute
  204. if match[4] == "h" {
  205. offUnit = time.Hour
  206. }
  207. if match[4] == "d" {
  208. offUnit = 24 * time.Hour
  209. }
  210. offNum, _ := strconv.ParseInt(match[3], 10, 64)
  211. end = end.Add(-time.Duration(offNum) * offUnit)
  212. durUnit := time.Minute
  213. if match[2] == "h" {
  214. durUnit = time.Hour
  215. }
  216. if match[2] == "d" {
  217. durUnit = 24 * time.Hour
  218. }
  219. durNum, _ := strconv.ParseInt(match[1], 10, 64)
  220. start := end.Add(-time.Duration(durNum) * durUnit)
  221. return NewWindow(&start, &end), nil
  222. }
  223. // Match timestamp pairs, e.g. "1586822400,1586908800" or "1586822400-1586908800"
  224. match = timestampPairRegex.FindStringSubmatch(window)
  225. if match != nil {
  226. s, _ := strconv.ParseInt(match[1], 10, 64)
  227. e, _ := strconv.ParseInt(match[2], 10, 64)
  228. start := time.Unix(s, 0)
  229. end := time.Unix(e, 0)
  230. return NewWindow(&start, &end), nil
  231. }
  232. // Match RFC3339 pairs, e.g. "2020-04-01T00:00:00Z,2020-04-03T00:00:00Z"
  233. match = rfcRegex.FindStringSubmatch(window)
  234. if match != nil {
  235. start, _ := time.Parse(time.RFC3339, match[1])
  236. end, _ := time.Parse(time.RFC3339, match[2])
  237. return NewWindow(&start, &end), nil
  238. }
  239. return Window{nil, nil}, fmt.Errorf("illegal window: %s", window)
  240. }
  241. // ApproximatelyEqual returns true if the start and end times of the two windows,
  242. // respectively, are within the given threshold of each other.
  243. func (w Window) ApproximatelyEqual(that Window, threshold time.Duration) bool {
  244. return approxEqual(w.start, that.start, threshold) && approxEqual(w.end, that.end, threshold)
  245. }
  246. func approxEqual(x *time.Time, y *time.Time, threshold time.Duration) bool {
  247. // both times are nil, so they are equal
  248. if x == nil && y == nil {
  249. return true
  250. }
  251. // one time is nil, but the other is not, so they are not equal
  252. if x == nil || y == nil {
  253. return false
  254. }
  255. // neither time is nil, so they are approximately close if their times are
  256. // within the given threshold
  257. delta := math.Abs((*x).Sub(*y).Seconds())
  258. return delta < threshold.Seconds()
  259. }
  260. func (w Window) Clone() Window {
  261. var start, end *time.Time
  262. var s, e time.Time
  263. if w.start != nil {
  264. s = *w.start
  265. start = &s
  266. }
  267. if w.end != nil {
  268. e = *w.end
  269. end = &e
  270. }
  271. return NewWindow(start, end)
  272. }
  273. func (w Window) Contains(t time.Time) bool {
  274. if w.start != nil && t.Before(*w.start) {
  275. return false
  276. }
  277. if w.end != nil && t.After(*w.end) {
  278. return false
  279. }
  280. return true
  281. }
  282. func (w Window) ContainsWindow(that Window) bool {
  283. // only support containing closed windows for now
  284. // could check if openness is compatible with closure
  285. if that.IsOpen() {
  286. return false
  287. }
  288. return w.Contains(*that.start) && w.Contains(*that.end)
  289. }
  290. func (w Window) Duration() time.Duration {
  291. if w.IsOpen() {
  292. // TODO test
  293. return time.Duration(math.Inf(1.0))
  294. }
  295. return w.end.Sub(*w.start)
  296. }
  297. func (w Window) End() *time.Time {
  298. return w.end
  299. }
  300. func (w Window) Equal(that Window) bool {
  301. if w.start != nil && that.start != nil && !w.start.Equal(*that.start) {
  302. // starts are not nil, but not equal
  303. return false
  304. }
  305. if w.end != nil && that.end != nil && !w.end.Equal(*that.end) {
  306. // ends are not nil, but not equal
  307. return false
  308. }
  309. if (w.start == nil && that.start != nil) || (w.start != nil && that.start == nil) {
  310. // one start is nil, the other is not
  311. return false
  312. }
  313. if (w.end == nil && that.end != nil) || (w.end != nil && that.end == nil) {
  314. // one end is nil, the other is not
  315. return false
  316. }
  317. // either both starts are nil, or they match; likewise for the ends
  318. return true
  319. }
  320. func (w Window) ExpandStart(start time.Time) Window {
  321. if w.start == nil || start.Before(*w.start) {
  322. w.start = &start
  323. }
  324. return w
  325. }
  326. func (w Window) ExpandEnd(end time.Time) Window {
  327. if w.end == nil || end.After(*w.end) {
  328. w.end = &end
  329. }
  330. return w
  331. }
  332. func (w Window) Expand(that Window) Window {
  333. if that.start == nil {
  334. w.start = nil
  335. } else {
  336. w = w.ExpandStart(*that.start)
  337. }
  338. if that.end == nil {
  339. w.end = nil
  340. } else {
  341. w = w.ExpandEnd(*that.end)
  342. }
  343. return w
  344. }
  345. func (w Window) ContractStart(start time.Time) Window {
  346. if w.start == nil || start.After(*w.start) {
  347. w.start = &start
  348. }
  349. return w
  350. }
  351. func (w Window) ContractEnd(end time.Time) Window {
  352. if w.end == nil || end.Before(*w.end) {
  353. w.end = &end
  354. }
  355. return w
  356. }
  357. func (w Window) Contract(that Window) Window {
  358. if that.start != nil {
  359. w = w.ContractStart(*that.start)
  360. }
  361. if that.end != nil {
  362. w = w.ContractEnd(*that.end)
  363. }
  364. return w
  365. }
  366. func (w Window) Hours() float64 {
  367. if w.IsOpen() {
  368. return math.Inf(1)
  369. }
  370. return w.end.Sub(*w.start).Hours()
  371. }
  372. //IsEmpty a Window is empty if it does not have a start and an end
  373. func (w Window) IsEmpty() bool {
  374. return w.start == nil && w.end == nil
  375. }
  376. //HasDuration a Window has duration if neither start and end are not nil and not equal
  377. func (w Window) HasDuration() bool {
  378. return !w.IsOpen() && !w.end.Equal(*w.Start())
  379. }
  380. //IsNegative a Window is negative if start and end are not null and end is before start
  381. func (w Window) IsNegative() bool {
  382. return !w.IsOpen() && w.end.Before(*w.Start())
  383. }
  384. //IsOpen a Window is open if it has a nil start or end
  385. func (w Window) IsOpen() bool {
  386. return w.start == nil || w.end == nil
  387. }
  388. // TODO:CLEANUP make this unmarshalable (make Start and End public)
  389. func (w Window) MarshalJSON() ([]byte, error) {
  390. buffer := bytes.NewBufferString("{")
  391. if w.start != nil {
  392. buffer.WriteString(fmt.Sprintf("\"start\":\"%s\",", w.start.Format(time.RFC3339)))
  393. } else {
  394. buffer.WriteString(fmt.Sprintf("\"start\":\"%s\",", "null"))
  395. }
  396. if w.end != nil {
  397. buffer.WriteString(fmt.Sprintf("\"end\":\"%s\"", w.end.Format(time.RFC3339)))
  398. } else {
  399. buffer.WriteString(fmt.Sprintf("\"end\":\"%s\"", "null"))
  400. }
  401. buffer.WriteString("}")
  402. return buffer.Bytes(), nil
  403. }
  404. func (w Window) Minutes() float64 {
  405. if w.IsOpen() {
  406. return math.Inf(1)
  407. }
  408. return w.end.Sub(*w.start).Minutes()
  409. }
  410. // Overlaps returns true iff the two given Windows share an amount of temporal
  411. // coverage.
  412. // TODO complete (with unit tests!) and then implement in AllocationSet.accumulate
  413. // TODO:CLEANUP
  414. // func (w Window) Overlaps(x Window) bool {
  415. // if (w.start == nil && w.end == nil) || (x.start == nil && x.end == nil) {
  416. // // one window is completely open, so overlap is guaranteed
  417. // // <---------->
  418. // // ?------?
  419. // return true
  420. // }
  421. // // Neither window is completely open (nil, nil), but one or the other might
  422. // // still be future- or past-open.
  423. // if w.start == nil {
  424. // // w is past-open, future-closed
  425. // // <------]
  426. // if x.start != nil && !x.start.Before(*w.end) {
  427. // // x starts after w ends (or eq)
  428. // // <------]
  429. // // [------?
  430. // return false
  431. // }
  432. // // <-----]
  433. // // ?-----?
  434. // return true
  435. // }
  436. // if w.end == nil {
  437. // // w is future-open, past-closed
  438. // // [------>
  439. // if x.end != nil && !x.end.After(*w.end) {
  440. // // x ends before w begins (or eq)
  441. // // [------>
  442. // // ?------]
  443. // return false
  444. // }
  445. // // [------>
  446. // // ?------?
  447. // return true
  448. // }
  449. // // Now we know w is closed, but we don't know about x
  450. // // [------]
  451. // // ?------?
  452. // if x.start == nil {
  453. // // TODO
  454. // }
  455. // if x.end == nil {
  456. // // TODO
  457. // }
  458. // // Both are closed.
  459. // if !x.start.Before(*w.end) && !x.end.Before(*w.end) {
  460. // // x starts and ends after w ends
  461. // // [------]
  462. // // [------]
  463. // return false
  464. // }
  465. // if !x.start.After(*w.start) && !x.end.After(*w.start) {
  466. // // x starts and ends before w starts
  467. // // [------]
  468. // // [------]
  469. // return false
  470. // }
  471. // // w and x must overlap
  472. // // [------]
  473. // // [------]
  474. // return true
  475. // }
  476. func (w Window) Set(start, end *time.Time) {
  477. w.start = start
  478. w.end = end
  479. }
  480. // Shift adds the given duration to both the start and end times of the window
  481. func (w Window) Shift(dur time.Duration) Window {
  482. if w.start != nil {
  483. s := w.start.Add(dur)
  484. w.start = &s
  485. }
  486. if w.end != nil {
  487. e := w.end.Add(dur)
  488. w.end = &e
  489. }
  490. return w
  491. }
  492. func (w Window) Start() *time.Time {
  493. return w.start
  494. }
  495. func (w Window) String() string {
  496. if w.start == nil && w.end == nil {
  497. return "[nil, nil)"
  498. }
  499. if w.start == nil {
  500. return fmt.Sprintf("[nil, %s)", w.end.Format("2006-01-02T15:04:05-0700"))
  501. }
  502. if w.end == nil {
  503. return fmt.Sprintf("[%s, nil)", w.start.Format("2006-01-02T15:04:05-0700"))
  504. }
  505. return fmt.Sprintf("[%s, %s)", w.start.Format("2006-01-02T15:04:05-0700"), w.end.Format("2006-01-02T15:04:05-0700"))
  506. }
  507. // DurationOffset returns durations representing the duration and offset of the
  508. // given window
  509. func (w Window) DurationOffset() (time.Duration, time.Duration, error) {
  510. if w.IsOpen() || w.IsNegative() {
  511. return 0, 0, fmt.Errorf("illegal window: %s", w)
  512. }
  513. duration := w.Duration()
  514. offset := time.Since(*w.End())
  515. return duration, offset, nil
  516. }
  517. // DurationOffsetForPrometheus returns strings representing durations for the
  518. // duration and offset of the given window, factoring in the Thanos offset if
  519. // necessary. Whereas duration is a simple duration string (e.g. "1d"), the
  520. // offset includes the word "offset" (e.g. " offset 2d") so that the values
  521. // returned can be used directly in the formatting string "some_metric[%s]%s"
  522. // to generate the query "some_metric[1d] offset 2d".
  523. func (w Window) DurationOffsetForPrometheus() (string, string, error) {
  524. duration, offset, err := w.DurationOffset()
  525. if err != nil {
  526. return "", "", err
  527. }
  528. // If using Thanos, increase offset to 3 hours, reducing the duration by
  529. // equal measure to maintain the same starting point.
  530. thanosDur := thanos.OffsetDuration()
  531. if offset < thanosDur && env.IsThanosEnabled() {
  532. diff := thanosDur - offset
  533. offset += diff
  534. duration -= diff
  535. }
  536. // If duration < 0, return an error
  537. if duration < 0 {
  538. return "", "", fmt.Errorf("negative duration: %s", duration)
  539. }
  540. // Negative offset means that the end time is in the future. Prometheus
  541. // fails for non-positive offset values, so shrink the duration and
  542. // remove the offset altogether.
  543. if offset < 0 {
  544. duration = duration + offset
  545. offset = 0
  546. }
  547. durStr, offStr := timeutil.DurationOffsetStrings(duration, offset)
  548. if offset < time.Minute {
  549. offStr = ""
  550. } else {
  551. offStr = " offset " + offStr
  552. }
  553. return durStr, offStr, nil
  554. }
  555. // DurationOffsetStrings returns formatted, Prometheus-compatible strings representing
  556. // the duration and offset of the window in terms of days, hours, minutes, or seconds;
  557. // e.g. ("7d", "1441m", "30m", "1s", "")
  558. func (w Window) DurationOffsetStrings() (string, string) {
  559. dur, off, err := w.DurationOffset()
  560. if err != nil {
  561. return "", ""
  562. }
  563. return timeutil.DurationOffsetStrings(dur, off)
  564. }
  565. type BoundaryError struct {
  566. Requested Window
  567. Supported Window
  568. Message string
  569. }
  570. func NewBoundaryError(req, sup Window, msg string) *BoundaryError {
  571. return &BoundaryError{
  572. Requested: req,
  573. Supported: sup,
  574. Message: msg,
  575. }
  576. }
  577. func (be *BoundaryError) Error() string {
  578. if be == nil {
  579. return "<nil>"
  580. }
  581. return fmt.Sprintf("boundary error: requested %s; supported %s: %s", be.Requested, be.Supported, be.Message)
  582. }