From a9b95bca2d9e8a5f4e9e33d738bca79874090a23 Mon Sep 17 00:00:00 2001 From: Alex Dadgar Date: Mon, 30 Nov 2015 16:51:56 -0800 Subject: [PATCH] Add Periodic config to job --- jobspec/parse.go | 48 +++++++++++++++++ jobspec/parse_test.go | 17 +++++++ jobspec/test-fixtures/periodic-cron.hcl | 5 ++ nomad/structs/structs.go | 66 ++++++++++++++++++++++++ nomad/structs/structs_test.go | 68 ++++++++++++++++++++++++- 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 jobspec/test-fixtures/periodic-cron.hcl diff --git a/jobspec/parse.go b/jobspec/parse.go index e4af7b25a..b38a6323f 100644 --- a/jobspec/parse.go +++ b/jobspec/parse.go @@ -90,6 +90,7 @@ func parseJob(result *structs.Job, list *ast.ObjectList) error { delete(m, "constraint") delete(m, "meta") delete(m, "update") + delete(m, "periodic") // Set the ID and name to the object key result.ID = obj.Keys[0].Token.Value().(string) @@ -127,6 +128,13 @@ func parseJob(result *structs.Job, list *ast.ObjectList) error { } } + // If we have a periodic definition, then parse that + if o := listVal.Filter("periodic"); len(o.Items) > 0 { + if err := parsePeriodic(&result.Periodic, o); err != nil { + return err + } + } + // Parse out meta fields. These are in HCL as a list so we need // to iterate over them and merge them. if metaO := listVal.Filter("meta"); len(metaO.Items) > 0 { @@ -666,3 +674,43 @@ func parseUpdate(result *structs.UpdateStrategy, list *ast.ObjectList) error { } return dec.Decode(m) } + +func parsePeriodic(result *structs.PeriodicConfig, list *ast.ObjectList) error { + list = list.Elem() + if len(list.Items) > 1 { + return fmt.Errorf("only one 'periodic' block allowed per job") + } + + // Get our resource object + o := list.Items[0] + + var m map[string]interface{} + if err := hcl.DecodeObject(&m, o.Val); err != nil { + return err + } + + // Enabled by default if the periodic block exists. + if value, ok := m["enabled"]; !ok { + m["Enabled"] = true + } else { + enabled, err := parseBool(value) + if err != nil { + return fmt.Errorf("periodic.enabled should be set to true or false; %v", err) + } + m["Enabled"] = enabled + } + + // If "cron_spec" is provided, set the type to "cron" and store the spec. + if cron, ok := m["cron_spec"]; ok { + m["SpecType"] = structs.PeriodicSpecCron + m["Spec"] = cron + } + + // Build the constraint + var p structs.PeriodicConfig + if err := mapstructure.WeakDecode(m, &p); err != nil { + return err + } + *result = p + return nil +} diff --git a/jobspec/parse_test.go b/jobspec/parse_test.go index 0502009de..7abc6f04c 100644 --- a/jobspec/parse_test.go +++ b/jobspec/parse_test.go @@ -231,6 +231,23 @@ func TestParse(t *testing.T) { false, }, + { + "periodic-cron.hcl", + &structs.Job{ + ID: "foo", + Name: "foo", + Priority: 50, + Region: "global", + Type: "service", + Periodic: structs.PeriodicConfig{ + Enabled: true, + SpecType: structs.PeriodicSpecCron, + Spec: "*/5 * * *", + }, + }, + false, + }, + { "specify-job.hcl", &structs.Job{ diff --git a/jobspec/test-fixtures/periodic-cron.hcl b/jobspec/test-fixtures/periodic-cron.hcl new file mode 100644 index 000000000..2b1bd2b39 --- /dev/null +++ b/jobspec/test-fixtures/periodic-cron.hcl @@ -0,0 +1,5 @@ +job "foo" { + periodic { + cron_spec = "*/5 * * *" + } +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index a349bdb05..1a841de0e 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/gorhill/cronexpr" "github.com/hashicorp/go-msgpack/codec" "github.com/hashicorp/go-multierror" "github.com/hashicorp/go-version" @@ -760,6 +761,9 @@ type Job struct { // Update is used to control the update strategy Update UpdateStrategy + // Periodic is used to define the interval the job is run at. + Periodic PeriodicConfig + // Meta is used to associate arbitrary metadata with this // job. This is opaque to Nomad. Meta map[string]string @@ -841,6 +845,13 @@ func (j *Job) Validate() error { mErr.Errors = append(mErr.Errors, outer) } } + + // Validate periodic is only used with batch jobs. + if j.Periodic.Enabled && j.Type != JobTypeBatch { + mErr.Errors = append(mErr.Errors, + fmt.Errorf("Periodic can only be used with %q scheduler", JobTypeBatch)) + } + return mErr.ErrorOrNil() } @@ -895,6 +906,61 @@ func (u *UpdateStrategy) Rolling() bool { return u.Stagger > 0 && u.MaxParallel > 0 } +const ( + // PeriodicSpecCron is used for a cron spec. + PeriodicSpecCron = "cron" +) + +// Periodic defines the interval a job should be run at. +type PeriodicConfig struct { + // Enabled determines if the job should be run periodically. + Enabled bool + + // Spec specifies the interval the job should be run as. It is parsed based + // on the SpecType. + Spec string + + // SpecType defines the format of the spec. + SpecType string +} + +func (p *PeriodicConfig) Validate() error { + if !p.Enabled { + return nil + } + + if p.SpecType == "" { + return fmt.Errorf("Must specify a spec type to be able to parse the interval") + } + + 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) + } + default: + return fmt.Errorf("Unknown specification type %q", p.SpecType) + } + + return nil +} + +// Next returns the closest time instant matching the spec that is after the +// passed time. If no matching instance exists, the zero value of time.Time is +// returned. The `time.Location` of the returned value matches that of the +// passed time. +func (p *PeriodicConfig) Next(fromTime time.Time) time.Time { + switch p.SpecType { + case PeriodicSpecCron: + if e, err := cronexpr.Parse(p.Spec); err == nil { + return e.Next(fromTime) + } + } + + return time.Time{} +} + // RestartPolicy influences how Nomad restarts Tasks when they // crash or fail. type RestartPolicy struct { diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index c763465ea..bfedb4b04 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -1,11 +1,12 @@ package structs import ( - "github.com/hashicorp/go-multierror" "reflect" "strings" "testing" "time" + + "github.com/hashicorp/go-multierror" ) func TestJob_Validate(t *testing.T) { @@ -34,6 +35,18 @@ func TestJob_Validate(t *testing.T) { t.Fatalf("err: %s", err) } + j = &Job{ + Type: JobTypeService, + Periodic: PeriodicConfig{ + Enabled: true, + }, + } + err = j.Validate() + mErr = err.(*multierror.Error) + if !strings.Contains(mErr.Error(), "Periodic") { + t.Fatalf("err: %s", err) + } + j = &Job{ Region: "global", ID: GenerateUUID(), @@ -488,3 +501,56 @@ func TestJob_ExpandServiceNames(t *testing.T) { } } + +func TestPeriodicConfig_EnabledInvalid(t *testing.T) { + // Create a config that is enabled but with no interval specified. + p := &PeriodicConfig{Enabled: true} + if err := p.Validate(); err == nil { + t.Fatal("Enabled PeriodicConfig with no spec or type shouldn't be valid") + } + + // Create a config that is enabled, with a spec but no type specified. + p = &PeriodicConfig{Enabled: true, Spec: "foo"} + if err := p.Validate(); err == nil { + t.Fatal("Enabled PeriodicConfig with no spec type shouldn't be valid") + } + + // Create a config that is enabled, with a spec type but no spec specified. + p = &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron} + if err := p.Validate(); err == nil { + t.Fatal("Enabled PeriodicConfig with no spec shouldn't be valid") + } +} + +func TestPeriodicConfig_InvalidCron(t *testing.T) { + specs := []string{"foo", "* *", "@foo"} + for _, spec := range specs { + p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec} + if err := p.Validate(); err == nil { + t.Fatal("Invalid cron spec") + } + } +} + +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} + if err := p.Validate(); err != nil { + t.Fatal("Passed valid cron") + } + } +} + +func TestPeriodicConfig_NextCron(t *testing.T) { + from := time.Date(2009, time.November, 10, 23, 22, 30, 0, time.UTC) + specs := []string{"0 0 29 2 * 1980", "*/5 * * * *"} + 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} + n := p.Next(from) + if expected[i] != n { + t.Fatalf("Next(%v) returned %v; want %v", from, n, expected[i]) + } + } +}