Merge pull request #2321 from hashicorp/f-timezone
Allow specification of timezones in Periodic Jobs
This commit is contained in:
commit
10735d1c0f
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -334,6 +334,7 @@ func TestParse(t *testing.T) {
|
|||
SpecType: structs.PeriodicSpecCron,
|
||||
Spec: "*/5 * * *",
|
||||
ProhibitOverlap: true,
|
||||
TimeZone: "Europe/Minsk",
|
||||
},
|
||||
},
|
||||
false,
|
||||
|
|
|
@ -2,5 +2,6 @@ job "foo" {
|
|||
periodic {
|
||||
cron = "*/5 * * *"
|
||||
prohibit_overlap = true
|
||||
time_zone = "Europe/Minsk"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue