Merge pull request #2321 from hashicorp/f-timezone

Allow specification of timezones in Periodic Jobs
This commit is contained in:
Alex Dadgar 2017-02-17 11:32:14 -08:00 committed by GitHub
commit 10735d1c0f
14 changed files with 157 additions and 11 deletions

View File

@ -211,6 +211,15 @@ type PeriodicConfig struct {
Spec string
SpecType string
ProhibitOverlap bool
TimeZone *string
}
func (p *PeriodicConfig) GetLocation() (*time.Location, error) {
if p.TimeZone == nil || *p.TimeZone == "" {
return time.UTC, nil
}
return time.LoadLocation(*p.TimeZone)
}
// ParameterizedJobConfig is used to configure the parameterized job.

View File

@ -218,8 +218,9 @@ func formatDryRun(resp *api.JobPlanResponse, job *structs.Job) string {
}
if next := resp.NextPeriodicLaunch; !next.IsZero() {
now := time.Now().In(job.Periodic.GetLocation())
out += fmt.Sprintf("[green]- If submitted now, next periodic launch would be at %s (%s from now).\n",
formatTime(next), formatTimeDifference(time.Now().UTC(), next, time.Second))
formatTime(next), formatTimeDifference(now, next, time.Second))
}
out = strings.TrimSuffix(out, "\n")

View File

@ -243,7 +243,7 @@ func (c *RunCommand) Run(args []string) int {
if detach || periodic || paramjob {
c.Ui.Output("Job registration successful")
if periodic {
now := time.Now().UTC()
now := time.Now().In(job.Periodic.GetLocation())
next := job.Periodic.Next(now)
c.Ui.Output(fmt.Sprintf("Approximate next launch time: %s (%s from now)",
formatTime(next), formatTimeDifference(now, next, time.Second)))

View File

@ -139,6 +139,7 @@ func (c *StatusCommand) Run(args []string) int {
c.Ui.Error(fmt.Sprintf("Error converting job: %s", err))
return 1
}
sJob.Canonicalize()
periodic := sJob.IsPeriodic()
parameterized := sJob.IsParameterized()
@ -155,7 +156,7 @@ func (c *StatusCommand) Run(args []string) int {
}
if periodic {
now := time.Now().UTC()
now := time.Now().In(sJob.Periodic.GetLocation())
next := sJob.Periodic.Next(now)
basic = append(basic, fmt.Sprintf("Next Periodic Launch|%s",
fmt.Sprintf("%s (%s from now)",

View File

@ -1172,6 +1172,7 @@ func parsePeriodic(result **structs.PeriodicConfig, list *ast.ObjectList) error
"enabled",
"cron",
"prohibit_overlap",
"time_zone",
}
if err := checkHCLKeys(o.Val, valid); err != nil {
return err

View File

@ -334,6 +334,7 @@ func TestParse(t *testing.T) {
SpecType: structs.PeriodicSpecCron,
Spec: "*/5 * * *",
ProhibitOverlap: true,
TimeZone: "Europe/Minsk",
},
},
false,

View File

@ -2,5 +2,6 @@ job "foo" {
periodic {
cron = "*/5 * * *"
prohibit_overlap = true
time_zone = "Europe/Minsk"
}
}

View File

@ -687,7 +687,7 @@ func (j *Job) Plan(args *structs.JobPlanRequest, reply *structs.JobPlanResponse)
// If it is a periodic job calculate the next launch
if args.Job.IsPeriodic() && args.Job.Periodic.Enabled {
reply.NextPeriodicLaunch = args.Job.Periodic.Next(time.Now().UTC())
reply.NextPeriodicLaunch = args.Job.Periodic.Next(time.Now().In(args.Job.Periodic.GetLocation()))
}
reply.FailedTGAllocs = updatedEval.FailedTGAllocs

View File

@ -209,7 +209,7 @@ func (p *PeriodicDispatch) Add(job *structs.Job) error {
// Add or update the job.
p.tracked[job.ID] = job
next := job.Periodic.Next(time.Now().UTC())
next := job.Periodic.Next(time.Now().In(job.Periodic.GetLocation()))
if tracked {
if err := p.heap.Update(job, next); err != nil {
return fmt.Errorf("failed to update job %v launch time: %v", job.ID, err)
@ -289,7 +289,7 @@ func (p *PeriodicDispatch) ForceRun(jobID string) (*structs.Evaluation, error) {
}
p.l.Unlock()
return p.createEval(job, time.Now().UTC())
return p.createEval(job, time.Now().In(job.Periodic.GetLocation()))
}
// shouldRun returns whether the long lived run function should run.
@ -309,7 +309,7 @@ func (p *PeriodicDispatch) run() {
if launch.IsZero() {
launchCh = nil
} else {
launchDur := launch.Sub(time.Now().UTC())
launchDur := launch.Sub(time.Now().In(job.Periodic.GetLocation()))
launchCh = time.After(launchDur)
p.logger.Printf("[DEBUG] nomad.periodic: launching job %q in %s", job.ID, launchDur)
}

View File

@ -515,6 +515,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
Expected: &JobDiff{
@ -548,6 +549,12 @@ func TestJobDiff(t *testing.T) {
Old: "",
New: "foo",
},
{
Type: DiffTypeAdded,
Name: "TimeZone",
Old: "",
New: "Europe/Minsk",
},
},
},
},
@ -561,6 +568,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{},
@ -595,6 +603,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "",
},
{
Type: DiffTypeDeleted,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "",
},
},
},
},
@ -608,6 +622,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{
@ -616,6 +631,7 @@ func TestJobDiff(t *testing.T) {
Spec: "* * * * * *",
SpecType: "cron",
ProhibitOverlap: true,
TimeZone: "America/Los_Angeles",
},
},
Expected: &JobDiff{
@ -649,6 +665,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "cron",
},
{
Type: DiffTypeEdited,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "America/Los_Angeles",
},
},
},
},
@ -663,6 +685,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{
@ -671,6 +694,7 @@ func TestJobDiff(t *testing.T) {
Spec: "* * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
Expected: &JobDiff{
@ -704,6 +728,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "foo",
},
{
Type: DiffTypeNone,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "Europe/Minsk",
},
},
},
},

View File

@ -1175,6 +1175,10 @@ func (j *Job) Canonicalize() {
if j.ParameterizedJob != nil {
j.ParameterizedJob.Canonicalize()
}
if j.Periodic != nil {
j.Periodic.Canonicalize()
}
}
// Copy returns a deep copy of the Job. It is expected that callers use recover.
@ -1542,6 +1546,16 @@ type PeriodicConfig struct {
// ProhibitOverlap enforces that spawned jobs do not run in parallel.
ProhibitOverlap bool `mapstructure:"prohibit_overlap"`
// TimeZone is the user specified string that determines the time zone to
// launch against. The time zones must be specified from IANA Time Zone
// database, such as "America/New_York".
// Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
// Reference: https://www.iana.org/time-zones
TimeZone string `mapstructure:"time_zone"`
// location is the time zone to evaluate the launch time against
location *time.Location
}
func (p *PeriodicConfig) Copy() *PeriodicConfig {
@ -1558,23 +1572,41 @@ func (p *PeriodicConfig) Validate() error {
return nil
}
var mErr multierror.Error
if p.Spec == "" {
return fmt.Errorf("Must specify a spec")
multierror.Append(&mErr, fmt.Errorf("Must specify a spec"))
}
// Check if we got a valid time zone
if p.TimeZone != "" {
if _, err := time.LoadLocation(p.TimeZone); err != nil {
multierror.Append(&mErr, fmt.Errorf("Invalid time zone %q: %v", p.TimeZone, err))
}
}
switch p.SpecType {
case PeriodicSpecCron:
// Validate the cron spec
if _, err := cronexpr.Parse(p.Spec); err != nil {
return fmt.Errorf("Invalid cron spec %q: %v", p.Spec, err)
multierror.Append(&mErr, fmt.Errorf("Invalid cron spec %q: %v", p.Spec, err))
}
case PeriodicSpecTest:
// No-op
default:
return fmt.Errorf("Unknown periodic specification type %q", p.SpecType)
multierror.Append(&mErr, fmt.Errorf("Unknown periodic specification type %q", p.SpecType))
}
return nil
return mErr.ErrorOrNil()
}
func (p *PeriodicConfig) Canonicalize() {
// Load the location
l, err := time.LoadLocation(p.TimeZone)
if err != nil {
p.location = time.UTC
}
p.location = l
}
// Next returns the closest time instant matching the spec that is after the
@ -1615,6 +1647,17 @@ func (p *PeriodicConfig) Next(fromTime time.Time) time.Time {
return time.Time{}
}
// GetLocation returns the location to use for determining the time zone to run
// the periodic job against.
func (p *PeriodicConfig) GetLocation() *time.Location {
// Jobs pre 0.5.5 will not have this
if p.location != nil {
return p.location
}
return time.UTC
}
const (
// PeriodicLaunchSuffix is the string appended to the periodic jobs ID
// when launching derived instances of it.

View File

@ -1217,12 +1217,19 @@ func TestPeriodicConfig_EnabledInvalid(t *testing.T) {
if err := p.Validate(); err == nil {
t.Fatal("Enabled PeriodicConfig with no spec shouldn't be valid")
}
// Create a config that is enabled, with a bad time zone.
p = &PeriodicConfig{Enabled: true, TimeZone: "FOO"}
if err := p.Validate(); err == nil || !strings.Contains(err.Error(), "time zone") {
t.Fatal("Enabled PeriodicConfig with bad time zone shouldn't be valid: %v", err)
}
}
func TestPeriodicConfig_InvalidCron(t *testing.T) {
specs := []string{"foo", "* *", "@foo"}
for _, spec := range specs {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec}
p.Canonicalize()
if err := p.Validate(); err == nil {
t.Fatal("Invalid cron spec")
}
@ -1233,6 +1240,7 @@ func TestPeriodicConfig_ValidCron(t *testing.T) {
specs := []string{"0 0 29 2 *", "@hourly", "0 0-15 * * *"}
for _, spec := range specs {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec}
p.Canonicalize()
if err := p.Validate(); err != nil {
t.Fatal("Passed valid cron")
}
@ -1245,6 +1253,7 @@ func TestPeriodicConfig_NextCron(t *testing.T) {
expected := []time.Time{time.Time{}, time.Date(2009, time.November, 10, 23, 25, 0, 0, time.UTC)}
for i, spec := range specs {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec}
p.Canonicalize()
n := p.Next(from)
if expected[i] != n {
t.Fatalf("Next(%v) returned %v; want %v", from, n, expected[i])
@ -1252,6 +1261,45 @@ func TestPeriodicConfig_NextCron(t *testing.T) {
}
}
func TestPeriodicConfig_ValidTimeZone(t *testing.T) {
zones := []string{"Africa/Abidjan", "America/Chicago", "Europe/Minsk", "UTC"}
for _, zone := range zones {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: "0 0 29 2 * 1980", TimeZone: zone}
p.Canonicalize()
if err := p.Validate(); err != nil {
t.Fatal("Valid tz errored: %v", err)
}
}
}
func TestPeriodicConfig_DST(t *testing.T) {
// On Sun, Mar 12, 2:00 am 2017: +1 hour UTC
p := &PeriodicConfig{
Enabled: true,
SpecType: PeriodicSpecCron,
Spec: "0 2 11-12 3 * 2017",
TimeZone: "America/Los_Angeles",
}
p.Canonicalize()
t1 := time.Date(2017, time.March, 11, 1, 0, 0, 0, p.location)
t2 := time.Date(2017, time.March, 12, 1, 0, 0, 0, p.location)
// E1 is an 8 hour adjustment, E2 is a 7 hour adjustment
e1 := time.Date(2017, time.March, 11, 10, 0, 0, 0, time.UTC)
e2 := time.Date(2017, time.March, 12, 9, 0, 0, 0, time.UTC)
n1 := p.Next(t1).UTC()
n2 := p.Next(t2).UTC()
if !reflect.DeepEqual(e1, n1) {
t.Fatalf("Got %v; want %v", n1, e1)
}
if !reflect.DeepEqual(e2, n2) {
t.Fatalf("Got %v; want %v", n1, e1)
}
}
func TestRestartPolicy_Validate(t *testing.T) {
// Policy with acceptable restart options passes
p := &RestartPolicy{

View File

@ -265,6 +265,12 @@ The `Job` object supports the following keys:
* `Enabled` - `Enabled` determines whether the periodic job will spawn child
jobs.
* `time_zone` - Specifies the time zone to evaluate the next launch interval
against. This is useful when wanting to account for day light savings in
various time zones. The time zone must be parsable by Golang's
[LoadLocation](https://golang.org/pkg/time/#LoadLocation). The default is
UTC.
* `SpecType` - `SpecType` determines how Nomad is going to interpret the
periodic expression. `cron` is the only supported `SpecType` currently.

View File

@ -49,6 +49,11 @@ consistent evaluation when Nomad spans multiple time zones.
previous instances of this job have completed. This only applies to this job;
it does not prevent other periodic jobs from running at the same time.
- `time_zone` `(string: "UTC")` - Specifies the time zone to evaluate the next
launch interval against. This is useful when wanting to account for day light
savings in various time zones. The time zone must be parsable by Golang's
[LoadLocation](https://golang.org/pkg/time/#LoadLocation).
## `periodic` Examples
The following examples only show the `periodic` stanzas. Remember that the