diff --git a/contrib/diurnal/Dockerfile b/contrib/diurnal/Dockerfile new file mode 100644 index 00000000000..ad2d8848ea9 --- /dev/null +++ b/contrib/diurnal/Dockerfile @@ -0,0 +1,8 @@ +FROM busybox +MAINTAINER Muhammed Uluyol "uluyol@google.com" + +ADD dc /diurnal + +RUN chown root:users /diurnal && chmod 755 /diurnal + +ENTRYPOINT ["/diurnal"] diff --git a/contrib/diurnal/Makefile b/contrib/diurnal/Makefile new file mode 100644 index 00000000000..7dd28344561 --- /dev/null +++ b/contrib/diurnal/Makefile @@ -0,0 +1,24 @@ +.PHONY: build push vet test clean + +TAG = 0.5 +REPO = uluyol/kube-diurnal + +BIN = dc + +dc: dc.go time.go + CGO_ENABLED=0 godep go build -a -installsuffix cgo -o dc dc.go time.go + +vet: + godep go vet . + +test: + godep go test . + +build: $(BIN) + docker build -t $(REPO):$(TAG) . + +push: + docker push $(REPO):$(TAG) + +clean: + rm -f $(BIN) diff --git a/contrib/diurnal/README.md b/contrib/diurnal/README.md new file mode 100644 index 00000000000..e8055cdb121 --- /dev/null +++ b/contrib/diurnal/README.md @@ -0,0 +1,44 @@ +## Diurnal Controller +This controller manipulates the number of replicas maintained by a replication controller throughout the day based on a provided list of times of day (according to ISO 8601) and replica counts. It should be run under a replication controller that is in the same namespace as the replication controller that it is manipulating. + +For example, to set the replica counts of the pods with the labels "tier=backend,track=canary" to 10 at noon UTC and 6 at midnight UTC, we can use `-labels tier=backend,track=canary -times 00:00Z,12:00Z -counts 6,10`. An example replication controller config can be found [here](example-diurnal-controller.yaml). + +Instead of providing replica counts and times of day directly, you may use a script like the one below to generate them using mathematical functions. + +```python +from math import * + +import os +import sys + +def _day_to_2pi(t): + return float(t) * 2 * pi / (24*3600) + +def main(args): + if len(args) < 3: + print "Usage: %s sample_interval func" % (args[0],) + print "func should be a function of the variable t, where t will range from 0" + print "to 2pi over the course of the day" + sys.exit(1) + sampling_interval = int(args[1]) + exec "def f(t): return " + args[2] + i = 0 + times = [] + counts = [] + while i < 24*60*60: + hours = i / 3600 + left = i - hours*3600 + min = left / 60 + sec = left - min*60 + times.append("%dh%dm%ds" % (hours, min, sec)) + count = int(round(f(_day_to_2pi(i)))) + counts.append(str(count)) + i += sampling_interval + print "-times %s -counts %s" % (",".join(times), ",".join(counts)) + +if __name__ == "__main__": + main(sys.argv) +``` + + +[![Analytics](https://kubernetes-site.appspot.com/UA-36037335-10/GitHub/contrib/diurnal/README.md?pixel)]() diff --git a/contrib/diurnal/dc.go b/contrib/diurnal/dc.go new file mode 100644 index 00000000000..b9952c034cc --- /dev/null +++ b/contrib/diurnal/dc.go @@ -0,0 +1,283 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// An external diurnal controller for kubernetes. With this, it's possible to manage +// known replica counts that vary throughout the day. + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "os/signal" + "sort" + "strconv" + "strings" + "syscall" + "time" + + kclient "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + + "github.com/golang/glog" +) + +const dayPeriod = 24 * time.Hour + +type timeCount struct { + time time.Duration + count int +} + +func (tc timeCount) String() string { + h := tc.time / time.Hour + m := (tc.time % time.Hour) / time.Minute + s := (tc.time % time.Minute) / time.Second + if m == 0 && s == 0 { + return fmt.Sprintf("(%02dZ, %d)", h, tc.count) + } else if s == 0 { + return fmt.Sprintf("(%02d:%02dZ, %d)", h, m, tc.count) + } + return fmt.Sprintf("(%02d:%02d:%02dZ, %d)", h, m, s, tc.count) +} + +type byTime []timeCount + +func (tc byTime) Len() int { return len(tc) } +func (tc byTime) Swap(i, j int) { tc[i], tc[j] = tc[j], tc[i] } +func (tc byTime) Less(i, j int) bool { return tc[i].time < tc[j].time } + +func timeMustParse(layout, s string) time.Time { + t, err := time.Parse(layout, s) + if err != nil { + panic(err) + } + return t +} + +// first argument is a format string equivalent to HHMMSS. See time.Parse for details. +var epoch = timeMustParse("150405", "000000") + +func parseTimeRelative(s string) (time.Duration, error) { + t, err := parseTimeISO8601(s) + if err != nil { + return 0, fmt.Errorf("unable to parse %s: %v", s, err) + } + return (t.Sub(epoch) + dayPeriod) % dayPeriod, nil +} + +func parseTimeCounts(times string, counts string) ([]timeCount, error) { + ts := strings.Split(times, ",") + cs := strings.Split(counts, ",") + if len(ts) != len(cs) { + return nil, fmt.Errorf("provided %d times but %d replica counts", len(ts), len(cs)) + } + var tc []timeCount + for i := range ts { + t, err := parseTimeRelative(ts[i]) + if err != nil { + return nil, err + } + c, err := strconv.ParseInt(cs[i], 10, 64) + if c < 0 { + return nil, errors.New("counts must be non-negative") + } + if err != nil { + return nil, err + } + tc = append(tc, timeCount{t, int(c)}) + } + sort.Sort(byTime(tc)) + return tc, nil +} + +type Scaler struct { + timeCounts []timeCount + selector labels.Selector + start time.Time + pos int + done chan struct{} +} + +var posError = errors.New("could not find position") + +func findPos(tc []timeCount, cur int, offset time.Duration) int { + first := true + for i := cur; i != cur || first; i = (i + 1) % len(tc) { + if tc[i].time > offset { + return i + } + first = false + } + return 0 +} + +func (s *Scaler) setCount(c int) { + glog.Infof("scaling to %d replicas", c) + rcList, err := client.ReplicationControllers(namespace).List(s.selector) + if err != nil { + glog.Errorf("could not get replication controllers: %v", err) + return + } + for _, rc := range rcList.Items { + rc.Spec.Replicas = c + if _, err = client.ReplicationControllers(namespace).Update(&rc); err != nil { + glog.Errorf("unable to scale replication controller: %v", err) + } + } +} + +func (s *Scaler) timeOffset() time.Duration { + return time.Since(s.start) % dayPeriod +} + +func (s *Scaler) curpos(offset time.Duration) int { + return findPos(s.timeCounts, s.pos, offset) +} + +func (s *Scaler) scale() { + for { + select { + case <-s.done: + return + default: + offset := s.timeOffset() + s.pos = s.curpos(offset) + if s.timeCounts[s.pos].time < offset { + time.Sleep(dayPeriod - offset) + continue + } + time.Sleep(s.timeCounts[s.pos].time - offset) + s.setCount(s.timeCounts[s.pos].count) + } + } +} + +func (s *Scaler) Start() error { + now := time.Now().UTC() + s.start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + if *startNow { + s.start = now + } + + // set initial count + pos := s.curpos(s.timeOffset()) + // add the len to avoid getting a negative index + pos = (pos - 1 + len(s.timeCounts)) % len(s.timeCounts) + s.setCount(s.timeCounts[pos].count) + + s.done = make(chan struct{}) + go s.scale() + return nil +} + +func safeclose(c chan<- struct{}) (err error) { + defer func() { + if e := recover(); e != nil { + err = e.(error) + } + }() + close(c) + return nil +} + +func (s *Scaler) Stop() error { + if err := safeclose(s.done); err != nil { + return errors.New("already stopped scaling") + } + return nil +} + +var ( + counts = flag.String("counts", "", "replica counts, must have at least one (csv)") + times = flag.String("times", "", "times to set replica counts relative to UTC following ISO 8601 (csv)") + userLabels = flag.String("labels", "", "replication controller labels, syntax should follow https://godoc.org/github.com/GoogleCloudPlatform/kubernetes/pkg/labels#Parse") + startNow = flag.Bool("now", false, "times are relative to now not 0:00 UTC (for demos)") + local = flag.Bool("local", false, "set to true if running on local machine not within cluster") + localPort = flag.Int("localport", 8001, "port that kubectl proxy is running on (local must be true)") + + namespace string = os.Getenv("POD_NAMESPACE") + + client *kclient.Client +) + +const usageNotes = ` +counts and times must both be set and be of equal length. Example usage: + diurnal -labels name=redis-slave -times 00:00:00Z,06:00:00Z -counts 3,9 + diurnal -labels name=redis-slave -times 0600-0500,0900-0500,1700-0500,2200-0500 -counts 15,20,13,6 +` + +func usage() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + fmt.Fprint(os.Stderr, usageNotes) +} + +func main() { + flag.Usage = usage + flag.Parse() + + var ( + cfg *kclient.Config + err error + ) + if *local { + cfg = &kclient.Config{Host: fmt.Sprintf("http://localhost:%d", *localPort)} + } else { + cfg, err = kclient.InClusterConfig() + if err != nil { + glog.Errorf("failed to load config: %v", err) + flag.Usage() + os.Exit(1) + } + } + client, err = kclient.New(cfg) + + selector, err := labels.Parse(*userLabels) + if err != nil { + glog.Fatal(err) + } + tc, err := parseTimeCounts(*times, *counts) + if err != nil { + glog.Fatal(err) + } + if namespace == "" { + glog.Fatal("POD_NAMESPACE is not set. Set to the namespace of the replication controller if running locally.") + } + scaler := Scaler{timeCounts: tc, selector: selector} + if err != nil { + glog.Fatal(err) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGQUIT, + syscall.SIGTERM) + + glog.Info("starting scaling") + if err := scaler.Start(); err != nil { + glog.Fatal(err) + } + <-sigChan + glog.Info("stopping scaling") + if err := scaler.Stop(); err != nil { + glog.Fatal(err) + } +} diff --git a/contrib/diurnal/dc_test.go b/contrib/diurnal/dc_test.go new file mode 100644 index 00000000000..c7c7f331a04 --- /dev/null +++ b/contrib/diurnal/dc_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "testing" + "time" +) + +func equalsTimeCounts(a, b []timeCount) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].time != b[i].time || a[i].count != b[i].count { + return false + } + } + return true +} + +func TestParseTimeCounts(t *testing.T) { + cases := []struct { + times string + counts string + out []timeCount + err bool + }{ + { + "00:00:01Z,00:02Z,03:00Z,04:00Z", "1,4,1,8", []timeCount{ + {time.Second, 1}, + {2 * time.Minute, 4}, + {3 * time.Hour, 1}, + {4 * time.Hour, 8}, + }, false, + }, + { + "00:01Z,00:02Z,00:05Z,00:03Z", "1,2,3,4", []timeCount{ + {1 * time.Minute, 1}, + {2 * time.Minute, 2}, + {3 * time.Minute, 4}, + {5 * time.Minute, 3}, + }, false, + }, + {"00:00Z,00:01Z", "1,0", []timeCount{{0, 1}, {1 * time.Minute, 0}}, false}, + {"00:00+00,00:01+00:00,01:00Z", "0,-1,0", nil, true}, + {"-00:01Z,01:00Z", "0,1", nil, true}, + {"00:00Z", "1,2,3", nil, true}, + } + for i, test := range cases { + out, err := parseTimeCounts(test.times, test.counts) + if test.err && err == nil { + t.Errorf("case %d: expected error", i) + } else if !test.err && err != nil { + t.Errorf("case %d: unexpected error: %v", i, err) + } + if !test.err { + if !equalsTimeCounts(test.out, out) { + t.Errorf("case %d: expected timeCounts: %v got %v", i, test.out, out) + } + } + } +} + +func TestFindPos(t *testing.T) { + cases := []struct { + tc []timeCount + cur int + offset time.Duration + expected int + }{ + {[]timeCount{{0, 1}, {4, 0}}, 1, 1, 1}, + {[]timeCount{{0, 1}, {4, 0}}, 0, 1, 1}, + {[]timeCount{{0, 1}, {4, 0}}, 1, 70, 0}, + {[]timeCount{{5, 1}, {100, 9000}, {4000, 2}, {10000, 4}}, 0, 0, 0}, + {[]timeCount{{5, 1}, {100, 9000}, {4000, 2}, {10000, 4}}, 1, 5000, 3}, + {[]timeCount{{5, 1}, {100, 9000}, {4000, 2}, {10000, 4}}, 2, 10000000, 0}, + {[]timeCount{{5, 1}, {100, 9000}, {4000, 2}, {10000, 4}}, 0, 50, 1}, + } + for i, test := range cases { + pos := findPos(test.tc, test.cur, test.offset) + if pos != test.expected { + t.Errorf("case %d: expected %d got %d", i, test.expected, pos) + } + } +} diff --git a/contrib/diurnal/example-diurnal-controller.yaml b/contrib/diurnal/example-diurnal-controller.yaml new file mode 100644 index 00000000000..3883cbd8dfe --- /dev/null +++ b/contrib/diurnal/example-diurnal-controller.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: ReplicationController +metadata: + labels: + name: diurnal-controller + name: diurnal-controller +spec: + replicas: 1 + selector: + name: diurnal-controller + template: + metadata: + labels: + name: diurnal-controller + spec: + containers: + - args: ["-labels", "name=redis-slave", "-times", "00:00Z,00:02Z,01:00Z,02:30Z", "-counts", "3,7,6,9"] + resources: + limits: + cpu: 0.1 + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + image: uluyol/kube-diurnal:0.5 + name: diurnal-controller diff --git a/contrib/diurnal/time.go b/contrib/diurnal/time.go new file mode 100644 index 00000000000..6abcfbe591f --- /dev/null +++ b/contrib/diurnal/time.go @@ -0,0 +1,226 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "errors" + "fmt" + "time" +) + +type parseTimeState int + +const ( + sHour parseTimeState = iota + 1 + sMinute + sSecond + sUTC + sOffHour + sOffMinute +) + +var parseTimeStateString = map[parseTimeState]string{ + sHour: "hour", + sMinute: "minute", + sSecond: "second", + sUTC: "UTC", + sOffHour: "offset hour", + sOffMinute: "offset minute", +} + +type timeParseErr struct { + state parseTimeState +} + +func (t timeParseErr) Error() string { + return "expected two digits for " + parseTimeStateString[t.state] +} + +func getTwoDigits(s string) (int, bool) { + if len(s) >= 2 && '0' <= s[0] && s[0] <= '9' && '0' <= s[1] && s[1] <= '9' { + return int(s[0]-'0')*10 + int(s[1]-'0'), true + } + return 0, false +} + +func zoneChar(b byte) bool { + return b == 'Z' || b == '+' || b == '-' +} + +func validate(x, min, max int, name string) error { + if x < min || max < x { + return fmt.Errorf("the %s must be within the range %d...%d", name, min, max) + } + return nil +} + +type triState int + +const ( + unset triState = iota + setFalse + setTrue +) + +// parseTimeISO8601 parses times (without dates) according to the ISO 8601 +// standard. The standard time package can understand layouts which accept +// valid ISO 8601 input. However, these layouts also accept input which is +// not valid ISO 8601 (in particular, negative zero time offset or "-00"). +// Furthermore, there are a number of acceptable layouts, and to handle +// all of them using the time package requires trying them one at a time. +// This is error-prone, slow, not obviously correct, and again, allows +// a wider range of input to be accepted than is desirable. For these +// reasons, we implement ISO 8601 parsing without the use of the time +// package. +func parseTimeISO8601(s string) (time.Time, error) { + theTime := struct { + hour int + minute int + second int + utc triState + offNeg bool + offHour int + offMinute int + }{} + state := sHour + isExtended := false + for s != "" { + switch state { + case sHour: + v, ok := getTwoDigits(s) + if !ok { + return time.Time{}, timeParseErr{state} + } + theTime.hour = v + s = s[2:] + case sMinute: + if !zoneChar(s[0]) { + if s[0] == ':' { + isExtended = true + s = s[1:] + } + v, ok := getTwoDigits(s) + if !ok { + return time.Time{}, timeParseErr{state} + } + theTime.minute = v + s = s[2:] + } + case sSecond: + if !zoneChar(s[0]) { + if s[0] == ':' { + if isExtended { + s = s[1:] + } else { + return time.Time{}, errors.New("unexpected ':' before 'second' value") + } + } else if isExtended { + return time.Time{}, errors.New("expected ':' before 'second' value") + } + v, ok := getTwoDigits(s) + if !ok { + return time.Time{}, timeParseErr{state} + } + theTime.second = v + s = s[2:] + } + case sUTC: + if s[0] == 'Z' { + theTime.utc = setTrue + s = s[1:] + } else { + theTime.utc = setFalse + } + case sOffHour: + if theTime.utc == setTrue { + return time.Time{}, errors.New("unexpected offset, already specified UTC") + } + var sign int + if s[0] == '+' { + sign = 1 + } else if s[0] == '-' { + sign = -1 + theTime.offNeg = true + } else { + return time.Time{}, errors.New("offset must begin with '+' or '-'") + } + s = s[1:] + v, ok := getTwoDigits(s) + if !ok { + return time.Time{}, timeParseErr{state} + } + theTime.offHour = sign * v + s = s[2:] + case sOffMinute: + if s[0] == ':' { + if isExtended { + s = s[1:] + } else { + return time.Time{}, errors.New("unexpected ':' before 'minute' value") + } + } else if isExtended { + return time.Time{}, errors.New("expected ':' before 'second' value") + } + v, ok := getTwoDigits(s) + if !ok { + return time.Time{}, timeParseErr{state} + } + theTime.offMinute = v + s = s[2:] + default: + return time.Time{}, errors.New("an unknown error occured") + } + state++ + } + if err := validate(theTime.hour, 0, 23, "hour"); err != nil { + return time.Time{}, err + } + if err := validate(theTime.minute, 0, 59, "minute"); err != nil { + return time.Time{}, err + } + if err := validate(theTime.second, 0, 59, "second"); err != nil { + return time.Time{}, err + } + if err := validate(theTime.offHour, -12, 14, "offset hour"); err != nil { + return time.Time{}, err + } + if err := validate(theTime.offMinute, 0, 59, "offset minute"); err != nil { + return time.Time{}, err + } + if theTime.offNeg && theTime.offHour == 0 && theTime.offMinute == 0 { + return time.Time{}, errors.New("an offset of -00 may not be used, must use +00") + } + var ( + loc *time.Location + err error + ) + if theTime.utc == setTrue { + loc, err = time.LoadLocation("UTC") + if err != nil { + panic(err) + } + } else if theTime.utc == setFalse { + loc = time.FixedZone("Zone", theTime.offMinute*60+theTime.offHour*3600) + } else { + loc, err = time.LoadLocation("Local") + if err != nil { + panic(err) + } + } + t := time.Date(1, time.January, 1, theTime.hour, theTime.minute, theTime.second, 0, loc) + return t, nil +} diff --git a/contrib/diurnal/time_test.go b/contrib/diurnal/time_test.go new file mode 100644 index 00000000000..56e7bff5b9a --- /dev/null +++ b/contrib/diurnal/time_test.go @@ -0,0 +1,104 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "testing" + "time" +) + +func TestParseTimeISO8601(t *testing.T) { + cases := []struct { + input string + expected time.Time + err bool + }{ + {"00", timeMustParse("15", "00"), false}, + {"49", time.Time{}, true}, + {"-2", time.Time{}, true}, + {"12:34:56", timeMustParse("15:04:05", "12:34:56"), false}, + {"123456", timeMustParse("15:04:05", "12:34:56"), false}, + {"12:34", timeMustParse("15:04:05", "12:34:00"), false}, + {"1234", timeMustParse("15:04:05", "12:34:00"), false}, + {"1234:56", time.Time{}, true}, + {"12:3456", time.Time{}, true}, + {"12:34:96", time.Time{}, true}, + {"12:34:-00", time.Time{}, true}, + {"123476", time.Time{}, true}, + {"12:-34", time.Time{}, true}, + {"12104", time.Time{}, true}, + + {"00Z", timeMustParse("15 MST", "00 UTC"), false}, + {"-2Z", time.Time{}, true}, + {"12:34:56Z", timeMustParse("15:04:05 MST", "12:34:56 UTC"), false}, + {"12:34Z", timeMustParse("15:04:05 MST", "12:34:00 UTC"), false}, + {"12:34:-00Z", time.Time{}, true}, + {"12104Z", time.Time{}, true}, + + {"00+00", timeMustParse("15 MST", "00 UTC"), false}, + {"-2+03", time.Time{}, true}, + {"11:34:56+12", timeMustParse("15:04:05 MST", "23:34:56 UTC"), false}, + {"12:34:14+10:30", timeMustParse("15:04:05 MST", "23:04:00 UTC"), false}, + {"12:34:-00+10", time.Time{}, true}, + {"1210+00:00", time.Time{}, true}, + {"12:10+0000", time.Time{}, true}, + {"1210Z+00", time.Time{}, true}, + + {"00-00", time.Time{}, true}, + {"-2-03", time.Time{}, true}, + {"11:34:56-11", timeMustParse("15:04:05 MST", "00:34:56 UTC"), false}, + {"12:34:14-10:30", timeMustParse("15:04:05 MST", "02:04:00 UTC"), false}, + {"12:34:-00-10", time.Time{}, true}, + {"1210-00:00", time.Time{}, true}, + {"12:10-0000", time.Time{}, true}, + {"1210Z-00", time.Time{}, true}, + + // boundary cases + {"-01", time.Time{}, true}, + {"00", timeMustParse("15", "00"), false}, + {"23", timeMustParse("15", "23"), false}, + {"24", time.Time{}, true}, + {"00:-01", time.Time{}, true}, + {"00:00", timeMustParse("15:04", "00:00"), false}, + {"00:59", timeMustParse("15:04", "00:59"), false}, + {"00:60", time.Time{}, true}, + {"01:02:-01", time.Time{}, true}, + {"01:02:00", timeMustParse("15:04:05", "01:02:00"), false}, + {"01:02:59", timeMustParse("15:04:05", "01:02:59"), false}, + {"01:02:60", time.Time{}, true}, + {"01:02:03-13", time.Time{}, true}, + {"01:02:03-12", timeMustParse("15:04:05 MST", "01:02:03 UTC").Add(-12 * time.Hour), false}, + {"01:02:03+14", timeMustParse("15:04:05 MST", "15:02:03 UTC"), false}, + {"01:02:03+15", time.Time{}, true}, + } + for i, test := range cases { + curTime, err := parseTimeISO8601(test.input) + if test.err { + if err == nil { + t.Errorf("case %d [%s]: expected error, got: %v", i, test.input, curTime) + } + continue + } + if err != nil { + t.Errorf("case %d [%s]: unexpected error: %v", i, test.input, err) + continue + } + if test.expected.Equal(curTime) { + t.Errorf("case %d [%s]: expected: %v got: %v", i, test.input, test.expected, curTime) + } + } +}