Skip to content

Commit

Permalink
Merge pull request #2321 from hashicorp/f-timezone
Browse files Browse the repository at this point in the history
Allow specification of timezones in Periodic Jobs
  • Loading branch information
dadgar committed Feb 17, 2017
2 parents f812cbc + 47420ba commit fb4b6bd
Show file tree
Hide file tree
Showing 14 changed files with 157 additions and 11 deletions.
9 changes: 9 additions & 0 deletions api/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion command/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
3 changes: 2 additions & 1 deletion command/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions jobspec/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions jobspec/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ func TestParse(t *testing.T) {
SpecType: structs.PeriodicSpecCron,
Spec: "*/5 * * *",
ProhibitOverlap: true,
TimeZone: "Europe/Minsk",
},
},
false,
Expand Down
1 change: 1 addition & 0 deletions jobspec/test-fixtures/periodic-cron.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ job "foo" {
periodic {
cron = "*/5 * * *"
prohibit_overlap = true
time_zone = "Europe/Minsk"
}
}
2 changes: 1 addition & 1 deletion nomad/job_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions nomad/periodic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions nomad/structs/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
Expected: &JobDiff{
Expand Down Expand Up @@ -548,6 +549,12 @@ func TestJobDiff(t *testing.T) {
Old: "",
New: "foo",
},
{
Type: DiffTypeAdded,
Name: "TimeZone",
Old: "",
New: "Europe/Minsk",
},
},
},
},
Expand All @@ -561,6 +568,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{},
Expand Down Expand Up @@ -595,6 +603,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "",
},
{
Type: DiffTypeDeleted,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "",
},
},
},
},
Expand All @@ -608,6 +622,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{
Expand All @@ -616,6 +631,7 @@ func TestJobDiff(t *testing.T) {
Spec: "* * * * * *",
SpecType: "cron",
ProhibitOverlap: true,
TimeZone: "America/Los_Angeles",
},
},
Expected: &JobDiff{
Expand Down Expand Up @@ -649,6 +665,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "cron",
},
{
Type: DiffTypeEdited,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "America/Los_Angeles",
},
},
},
},
Expand All @@ -663,6 +685,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{
Expand All @@ -671,6 +694,7 @@ func TestJobDiff(t *testing.T) {
Spec: "* * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
Expected: &JobDiff{
Expand Down Expand Up @@ -704,6 +728,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "foo",
},
{
Type: DiffTypeNone,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "Europe/Minsk",
},
},
},
},
Expand Down
51 changes: 47 additions & 4 deletions nomad/structs/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions nomad/structs/structs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -1245,13 +1253,53 @@ 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])
}
}
}

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{
Expand Down
6 changes: 6 additions & 0 deletions website/source/docs/http/json-jobs.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading

0 comments on commit fb4b6bd

Please sign in to comment.