window.go 13 KB

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