From 88faa1bb7fe7c6e29d868d704e784fbdf75e3463 Mon Sep 17 00:00:00 2001 From: Samuel BERTHE Date: Tue, 13 Dec 2016 13:00:53 +0100 Subject: [PATCH] Improving Rundeck provider: scheduler (#9449) * feat(rundeck provider): Scheduling (crontab) * fix(govendor-upgrade): Rundeck api wrapper --- builtin/providers/rundeck/resource_job.go | 47 +++ .../providers/rundeck/resource_job_test.go | 1 + .../apparentlymart/go-rundeck-api/README.md | 9 + .../go-rundeck-api/rundeck/job.go | 129 +++++-- .../go-rundeck-api/rundeck/job_test.go | 314 ++++++++++++++++++ vendor/vendor.json | 6 + 6 files changed, 479 insertions(+), 27 deletions(-) create mode 100644 vendor/github.com/apparentlymart/go-rundeck-api/README.md create mode 100644 vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job_test.go diff --git a/builtin/providers/rundeck/resource_job.go b/builtin/providers/rundeck/resource_job.go index f751f07da3..e3a35b062c 100644 --- a/builtin/providers/rundeck/resource_job.go +++ b/builtin/providers/rundeck/resource_job.go @@ -2,6 +2,7 @@ package rundeck import ( "fmt" + "strings" "github.com/hashicorp/terraform/helper/schema" @@ -99,6 +100,11 @@ func resourceRundeckJob() *schema.Resource { Optional: true, }, + "schedule": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "option": &schema.Schema{ // This is a list because order is important when preserve_options_order is // set. When it's not set the order is unimportant but preserved by Rundeck/ @@ -455,6 +461,30 @@ func jobFromResourceData(d *schema.ResourceData) (*rundeck.JobDetail, error) { } } + if d.Get("schedule").(string) != "" { + schedule := strings.Split(d.Get("schedule").(string), " ") + if len(schedule) != 7 { + return nil, fmt.Errorf("Rundeck schedule must be formated like a cron expression, as defined here: http://www.quartz-scheduler.org/documentation/quartz-2.2.x/tutorials/tutorial-lesson-06.html") + } + job.Schedule = &rundeck.JobSchedule{ + Time: rundeck.JobScheduleTime{ + Seconds: schedule[0], + Minute: schedule[1], + Hour: schedule[2], + }, + Month: rundeck.JobScheduleMonth{ + Day: schedule[3], + Month: schedule[4], + }, + WeekDay: &rundeck.JobScheduleWeekDay{ + Day: schedule[5], + }, + Year: rundeck.JobScheduleYear{ + Year: schedule[6], + }, + } + } + return job, nil } @@ -562,5 +592,22 @@ func jobToResourceData(job *rundeck.JobDetail, d *schema.ResourceData) error { } d.Set("command", commandConfigsI) + if job.Schedule != nil { + schedule := []string{} + schedule = append(schedule, job.Schedule.Time.Seconds) + schedule = append(schedule, job.Schedule.Time.Minute) + schedule = append(schedule, job.Schedule.Time.Hour) + schedule = append(schedule, job.Schedule.Month.Day) + schedule = append(schedule, job.Schedule.Month.Month) + if job.Schedule.WeekDay != nil { + schedule = append(schedule, job.Schedule.WeekDay.Day) + } else { + schedule = append(schedule, "*") + } + schedule = append(schedule, job.Schedule.Year.Year) + + d.Set("schedule", strings.Join(schedule, " ")) + } + return nil } diff --git a/builtin/providers/rundeck/resource_job_test.go b/builtin/providers/rundeck/resource_job_test.go index 66482ef0f1..182f4a3cfa 100644 --- a/builtin/providers/rundeck/resource_job_test.go +++ b/builtin/providers/rundeck/resource_job_test.go @@ -92,6 +92,7 @@ resource "rundeck_job" "test" { allow_concurrent_executions = 1 max_thread_count = 1 rank_order = "ascending" + schedule = "0 0 12 * * * *" option { name = "foo" default_value = "bar" diff --git a/vendor/github.com/apparentlymart/go-rundeck-api/README.md b/vendor/github.com/apparentlymart/go-rundeck-api/README.md new file mode 100644 index 0000000000..18abfb3d33 --- /dev/null +++ b/vendor/github.com/apparentlymart/go-rundeck-api/README.md @@ -0,0 +1,9 @@ +# go-rundeck-api + +This is a Go client for the Rundeck HTTP API. It was primarily developed to back the Rundeck provider in [Terraform](https://terraform.io), but can be used standalone too. + +It should ``go install`` just like any other Go package: + +* ``go install github.com/apparentlymart/go-rundeck-api/rundeck`` + +For reference documentation, see [godoc](https://godoc.org/github.com/apparentlymart/go-rundeck-api/rundeck). diff --git a/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job.go b/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job.go index ca04ac35a1..275db85016 100644 --- a/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job.go +++ b/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job.go @@ -30,12 +30,57 @@ type JobDetail struct { GroupName string `xml:"group,omitempty"` ProjectName string `xml:"context>project,omitempty"` OptionsConfig *JobOptions `xml:"context>options,omitempty"` - Description string `xml:"description,omitempty"` + Description string `xml:"description"` LogLevel string `xml:"loglevel,omitempty"` - AllowConcurrentExecutions bool `xml:"multipleExecutions"` - Dispatch *JobDispatch `xml:"dispatch"` + AllowConcurrentExecutions bool `xml:"multipleExecutions,omitempty"` + Dispatch *JobDispatch `xml:"dispatch,omitempty"` CommandSequence *JobCommandSequence `xml:"sequence,omitempty"` + Timeout string `xml:"timeout,omitempty"` + Retry string `xml:"retry,omitempty"` NodeFilter *JobNodeFilter `xml:"nodefilters,omitempty"` + + /* If Dispatch is enabled, nodesSelectedByDefault is always present with true/false. + * by this reason omitempty cannot be present. + * This has to be handle by the user. + */ + NodesSelectedByDefault bool `xml:"nodesSelectedByDefault"` + Schedule *JobSchedule `xml:"schedule,omitempty"` +} + +type JobSchedule struct { + XMLName xml.Name `xml:"schedule"` + DayOfMonth *JobScheduleDayOfMonth `xml:"dayofmonth,omitempty"` + Time JobScheduleTime `xml:"time"` + Month JobScheduleMonth `xml:"month"` + WeekDay *JobScheduleWeekDay `xml:"weekday,omitempty"` + Year JobScheduleYear `xml:"year"` +} + +type JobScheduleDayOfMonth struct { + XMLName xml.Name `xml:"dayofmonth"` +} + +type JobScheduleMonth struct { + XMLName xml.Name `xml:"month"` + Day string `xml:"day,attr,omitempty"` + Month string `xml:"month,attr"` +} + +type JobScheduleYear struct { + XMLName xml.Name `xml:"year"` + Year string `xml:"year,attr"` +} + +type JobScheduleWeekDay struct { + XMLName xml.Name `xml:"weekday"` + Day string `xml:"day,attr"` +} + +type JobScheduleTime struct { + XMLName xml.Name `xml:"time"` + Hour string `xml:"hour,attr"` + Minute string `xml:"minute,attr"` + Seconds string `xml:"seconds,attr"` } type jobDetailList struct { @@ -53,48 +98,52 @@ type JobOptions struct { type JobOption struct { XMLName xml.Name `xml:"option"` - // The name of the option, which can be used to interpolate its value - // into job commands. - Name string `xml:"name,attr,omitempty"` - - // The default value of the option. - DefaultValue string `xml:"value,attr,omitempty"` - - // A sequence of predefined choices for this option. Mutually exclusive with ValueChoicesURL. - ValueChoices JobValueChoices `xml:"values,attr"` - - // A URL from which the predefined choices for this option will be retrieved. - // Mutually exclusive with ValueChoices - ValueChoicesURL string `xml:"valuesUrl,attr,omitempty"` + // If AllowsMultipleChoices is set, the string that will be used to delimit the multiple + // chosen options. + MultiValueDelimiter string `xml:"delimiter,attr,omitempty"` // If set, Rundeck will reject values that are not in the set of predefined choices. RequirePredefinedChoice bool `xml:"enforcedvalues,attr,omitempty"` + // When either ValueChoices or ValueChoicesURL is set, controls whether more than one + // choice may be selected as the value. + AllowsMultipleValues bool `xml:"multivalued,attr,omitempty"` + + // The name of the option, which can be used to interpolate its value + // into job commands. + Name string `xml:"name,attr,omitempty"` + // Regular expression to be used to validate the option value. ValidationRegex string `xml:"regex,attr,omitempty"` - // Description of the value to be shown in the Rundeck UI. - Description string `xml:"description,omitempty"` - // If set, Rundeck requires a value to be set for this option. IsRequired bool `xml:"required,attr,omitempty"` - // When either ValueChoices or ValueChoicesURL is set, controls whether more than one - // choice may be selected as the value. - AllowsMultipleValues bool `xml:"multivalued,attr,omitempty"` - - // If AllowsMultipleChoices is set, the string that will be used to delimit the multiple - // chosen options. - MultiValueDelimiter string `xml:"delimeter,attr,omitempty"` - // If set, the input for this field will be obscured in the UI. Useful for passwords // and other secrets. ObscureInput bool `xml:"secure,attr,omitempty"` + // If ObscureInput is set, StoragePath can be used to point out credentials. + StoragePath string `xml:"storagePath,attr,omitempty"` + + // The default value of the option. + DefaultValue string `xml:"value,attr,omitempty"` + // If set, the value can be accessed from scripts. ValueIsExposedToScripts bool `xml:"valueExposed,attr,omitempty"` + + // A sequence of predefined choices for this option. Mutually exclusive with ValueChoicesURL. + ValueChoices JobValueChoices `xml:"values,attr"` + + // A URL from which the predefined choices for this option will be retrieved. + // Mutually exclusive with ValueChoices + ValueChoicesURL string `xml:"valuesUrl,attr,omitempty"` + + // Description of the value to be shown in the Rundeck UI. + Description string `xml:"description,omitempty"` } + // JobValueChoices is a specialization of []string representing a sequence of predefined values // for a job option. type JobValueChoices []string @@ -112,6 +161,9 @@ type JobCommandSequence struct { // Sequence of commands to run in the sequence. Commands []JobCommand `xml:"command"` + + // Description + Description string `xml:"description,omitempty"` } // JobCommand describes a particular command to run within the sequence of commands on a job. @@ -120,9 +172,21 @@ type JobCommandSequence struct { type JobCommand struct { XMLName xml.Name + // If the Workflow keepgoing is false, this allows the Workflow to continue when the Error Handler is successful. + ContinueOnError bool `xml:"keepgoingOnSuccess,attr,omitempty"` + + // Description + Description string `xml:"description,omitempty"` + + // On error: + ErrorHandler *JobCommand `xml:"errorhandler,omitempty"` + // A literal shell command to run. ShellCommand string `xml:"exec,omitempty"` + // Add extension to the temporary filename. + FileExtension string `xml:"fileExtension,omitempty"` + // An inline program to run. This will be written to disk and executed, so if it is // a shell script it should have an appropriate #! line. Script string `xml:"script,omitempty"` @@ -133,6 +197,9 @@ type JobCommand struct { // When ScriptFile is set, the arguments to provide to the script when executing it. ScriptFileArgs string `xml:"scriptargs,omitempty"` + // ScriptInterpreter is used to execute (Script)File with. + ScriptInterpreter *JobCommandScriptInterpreter `xml:"scriptinterpreter,omitempty"` + // A reference to another job to run as this command. Job *JobCommandJobRef `xml:"jobref"` @@ -143,12 +210,20 @@ type JobCommand struct { NodeStepPlugin *JobPlugin `xml:"node-step-plugin"` } +// (Inline) Script interpreter +type JobCommandScriptInterpreter struct { + XMLName xml.Name `xml:"scriptinterpreter"` + InvocationString string `xml:",chardata"` + ArgsQuoted bool `xml:"argsquoted,attr,omitempty"` +} + // JobCommandJobRef is a reference to another job that will run as one of the commands of a job. type JobCommandJobRef struct { XMLName xml.Name `xml:"jobref"` Name string `xml:"name,attr"` GroupName string `xml:"group,attr"` RunForEachNode bool `xml:"nodeStep,attr"` + NodeFilter *JobNodeFilter `xml:"nodefilters,omitempty"` Arguments JobCommandJobRefArguments `xml:"arg"` } diff --git a/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job_test.go b/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job_test.go new file mode 100644 index 0000000000..aa54351b3d --- /dev/null +++ b/vendor/github.com/apparentlymart/go-rundeck-api/rundeck/job_test.go @@ -0,0 +1,314 @@ +package rundeck + +import ( + "fmt" + "testing" +) + +func TestUnmarshalJobDetail(t *testing.T) { + testUnmarshalXML(t, []unmarshalTest{ + unmarshalTest{ + "with-config", + `bazascending`, + &JobDetail{}, + func (rv interface {}) error { + v := rv.(*JobDetail) + if v.ID != "baz" { + return fmt.Errorf("got ID %s, but expecting baz", v.ID) + } + if v.Dispatch.RankOrder != "ascending" { + return fmt.Errorf("Dispatch.RankOrder = \"%v\", but expecting \"ascending\"", v.Dispatch.RankOrder) + } + return nil + }, + }, + unmarshalTest{ + "with-empty-config", + ``, + &JobPlugin{}, + func (rv interface {}) error { + v := rv.(*JobPlugin) + if v.Type != "foo-plugin" { + return fmt.Errorf("got Type %s, but expecting foo-plugin", v.Type) + } + if len(v.Config) != 0 { + return fmt.Errorf("got %i Config values, but expecting 0", len(v.Config)) + } + return nil + }, + }, + }) +} + +func TestMarshalJobPlugin(t *testing.T) { + testMarshalXML(t, []marshalTest{ + marshalTest{ + "with-config", + JobPlugin{ + Type: "foo-plugin", + Config: map[string]string{ + "woo": "foo", + "bar": "baz", + }, + }, + ``, + }, + marshalTest{ + "with-empty-config", + JobPlugin{ + Type: "foo-plugin", + Config: map[string]string{}, + }, + ``, + }, + marshalTest{ + "with-zero-value-config", + JobPlugin{ + Type: "foo-plugin", + }, + ``, + }, + }) +} + +func TestUnmarshalJobPlugin(t *testing.T) { + testUnmarshalXML(t, []unmarshalTest{ + unmarshalTest{ + "with-config", + ``, + &JobPlugin{}, + func (rv interface {}) error { + v := rv.(*JobPlugin) + if v.Type != "foo-plugin" { + return fmt.Errorf("got Type %s, but expecting foo-plugin", v.Type) + } + if len(v.Config) != 2 { + return fmt.Errorf("got %v Config values, but expecting 2", len(v.Config)) + } + if v.Config["woo"] != "foo" { + return fmt.Errorf("Config[\"woo\"] = \"%s\", but expecting \"foo\"", v.Config["woo"]) + } + if v.Config["bar"] != "baz" { + return fmt.Errorf("Config[\"bar\"] = \"%s\", but expecting \"baz\"", v.Config["bar"]) + } + return nil + }, + }, + unmarshalTest{ + "with-empty-config", + ``, + &JobPlugin{}, + func (rv interface {}) error { + v := rv.(*JobPlugin) + if v.Type != "foo-plugin" { + return fmt.Errorf("got Type %s, but expecting foo-plugin", v.Type) + } + if len(v.Config) != 0 { + return fmt.Errorf("got %i Config values, but expecting 0", len(v.Config)) + } + return nil + }, + }, + }) +} + +func TestMarshalJobCommand(t *testing.T) { + testMarshalXML(t, []marshalTest{ + marshalTest{ + "with-shell", + JobCommand{ + ShellCommand: "command", + }, + `command`, + }, + marshalTest{ + "with-script", + JobCommand{ + Script: "script", + }, + ``, + }, + marshalTest{ + "with-script-interpreter", + JobCommand{ + FileExtension: "sh", + Script: "Hello World!", + ScriptInterpreter: &JobCommandScriptInterpreter{ + InvocationString: "sudo", + }, + }, + `shsudo`, + }, + }) +} + +func TestUnmarshalJobCommand(t *testing.T) { + testUnmarshalXML(t, []unmarshalTest{ + unmarshalTest{ + "with-shell", + `command`, + &JobCommand{}, + func (rv interface {}) error { + v := rv.(*JobCommand) + if v.ShellCommand != "command" { + return fmt.Errorf("got ShellCommand %s, but expecting command", v.ShellCommand) + } + return nil + }, + }, + unmarshalTest{ + "with-script", + ``, + &JobCommand{}, + func (rv interface {}) error { + v := rv.(*JobCommand) + if v.Script != "script" { + return fmt.Errorf("got Script %s, but expecting script", v.Script) + } + return nil + }, + }, + unmarshalTest{ + "with-script-interpreter", + `shsudo`, + &JobCommand{}, + func (rv interface {}) error { + v := rv.(*JobCommand) + if v.FileExtension != "sh" { + return fmt.Errorf("got FileExtension %s, but expecting sh", v.FileExtension) + } + if v.Script != "Hello World!" { + return fmt.Errorf("got Script %s, but expecting Hello World!", v.Script) + } + if v.ScriptInterpreter == nil { + return fmt.Errorf("got %s, but expecting not nil", v.ScriptInterpreter) + } + if v.ScriptInterpreter.InvocationString != "sudo" { + return fmt.Errorf("got InvocationString %s, but expecting sudo", v.ScriptInterpreter.InvocationString) + } + return nil + }, + }, + }) +} + +func TestMarshalScriptInterpreter(t *testing.T) { + testMarshalXML(t, []marshalTest{ + marshalTest{ + "with-script-interpreter", + JobCommandScriptInterpreter{ + InvocationString: "sudo", + }, + `sudo`, + }, + marshalTest{ + "with-script-interpreter-quoted", + JobCommandScriptInterpreter{ + ArgsQuoted: true, + InvocationString: "sudo", + }, + `sudo`, + }, + }) +} + +func TestUnmarshalScriptInterpreter(t *testing.T) { + testUnmarshalXML(t, []unmarshalTest{ + unmarshalTest{ + "with-script-interpreter", + `sudo`, + &JobCommandScriptInterpreter{}, + func (rv interface {}) error { + v := rv.(*JobCommandScriptInterpreter) + if v.InvocationString != "sudo" { + return fmt.Errorf("got InvocationString %s, but expecting sudo", v.InvocationString) + } + if v.ArgsQuoted { + return fmt.Errorf("got ArgsQuoted %s, but expecting false", v.ArgsQuoted) + } + return nil + }, + }, + unmarshalTest{ + "with-script-interpreter-quoted", + `sudo`, + &JobCommandScriptInterpreter{}, + func (rv interface {}) error { + v := rv.(*JobCommandScriptInterpreter) + if v.InvocationString != "sudo" { + return fmt.Errorf("got InvocationString %s, but expecting sudo", v.InvocationString) + } + if ! v.ArgsQuoted { + return fmt.Errorf("got ArgsQuoted %s, but expecting true", v.ArgsQuoted) + } + return nil + }, + }, + }) +} + +func TestMarshalErrorHanlder(t *testing.T) { + testMarshalXML(t, []marshalTest{ + marshalTest{ + "with-errorhandler", + JobCommandSequence{ + ContinueOnError: true, + OrderingStrategy: "step-first", + Commands: []JobCommand{ + JobCommand{ + Script: "inline_script", + ErrorHandler: &JobCommand{ + ContinueOnError: true, + Script: "error_script", + }, + }, + }, + }, + ``, + }, + }) +} + + +func TestMarshalJobOption(t *testing.T) { + testMarshalXML(t, []marshalTest{ + marshalTest{ + "with-option-basic", + JobOption{ + Name: "basic", + }, + ``, + }, + marshalTest{ + "with-option-multivalued", + JobOption{ + Name: "Multivalued", + MultiValueDelimiter: "|", + RequirePredefinedChoice: true, + AllowsMultipleValues: true, + IsRequired: true, + ValueChoices: JobValueChoices([]string{"myValues"}), + }, + ``, + }, + marshalTest{ + "with-all-attributes", + JobOption{ + Name: "advanced", + MultiValueDelimiter: "|", + RequirePredefinedChoice: true, + AllowsMultipleValues: true, + ValidationRegex: ".+", + IsRequired: true, + ObscureInput: true, + StoragePath: "myKey", + DefaultValue: "myValue", + ValueIsExposedToScripts: true, + ValueChoices: JobValueChoices([]string{"myValues"}), + ValueChoicesURL: "myValuesUrl", + }, + ``, + }, + }) +} + diff --git a/vendor/vendor.json b/vendor/vendor.json index 07d7e90da1..d919c25688 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -365,6 +365,12 @@ "path": "github.com/apparentlymart/go-grafana-api", "revision": "d49f95c81c580a4e7a15244b9b12dce8f60750f4" }, + { + "checksumSHA1": "+2yCNqbcf7VcavAptooQReTGiHY=", + "path": "github.com/apparentlymart/go-rundeck-api", + "revision": "f6af74d34d1ef69a511c59173876fc1174c11f0d", + "revisionTime": "2016-08-26T14:30:32Z" + }, { "comment": "v0.0.1-1-g43fcd8f", "path": "github.com/apparentlymart/go-rundeck-api/rundeck",