Improving Rundeck provider: scheduler (#9449)

* feat(rundeck provider): Scheduling (crontab)

* fix(govendor-upgrade): Rundeck api wrapper
This commit is contained in:
Samuel BERTHE 2016-12-13 13:00:53 +01:00 committed by Paul Stack
parent e558cbddf4
commit 88faa1bb7f
6 changed files with 479 additions and 27 deletions

View File

@ -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
}

View File

@ -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"

View File

@ -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).

View File

@ -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,13 +98,40 @@ type JobOptions struct {
type JobOption struct {
XMLName xml.Name `xml:"option"`
// 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"`
// If set, Rundeck requires a value to be set for this option.
IsRequired bool `xml:"required,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"`
@ -67,34 +139,11 @@ type JobOption struct {
// Mutually exclusive with ValueChoices
ValueChoicesURL string `xml:"valuesUrl,attr,omitempty"`
// If set, Rundeck will reject values that are not in the set of predefined choices.
RequirePredefinedChoice bool `xml:"enforcedvalues,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 set, the value can be accessed from scripts.
ValueIsExposedToScripts bool `xml:"valueExposed,attr,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"`
}

View File

@ -0,0 +1,314 @@
package rundeck
import (
"fmt"
"testing"
)
func TestUnmarshalJobDetail(t *testing.T) {
testUnmarshalXML(t, []unmarshalTest{
unmarshalTest{
"with-config",
`<job><uuid>baz</uuid><dispatch><rankOrder>ascending</rankOrder></dispatch></job>`,
&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 type="foo-plugin"><configuration/></JobPlugin>`,
&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",
},
},
`<JobPlugin type="foo-plugin"><configuration><entry key="bar" value="baz"></entry><entry key="woo" value="foo"></entry></configuration></JobPlugin>`,
},
marshalTest{
"with-empty-config",
JobPlugin{
Type: "foo-plugin",
Config: map[string]string{},
},
`<JobPlugin type="foo-plugin"></JobPlugin>`,
},
marshalTest{
"with-zero-value-config",
JobPlugin{
Type: "foo-plugin",
},
`<JobPlugin type="foo-plugin"></JobPlugin>`,
},
})
}
func TestUnmarshalJobPlugin(t *testing.T) {
testUnmarshalXML(t, []unmarshalTest{
unmarshalTest{
"with-config",
`<JobPlugin type="foo-plugin"><configuration><entry key="woo" value="foo"/><entry key="bar" value="baz"/></configuration></JobPlugin>`,
&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 type="foo-plugin"><configuration/></JobPlugin>`,
&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",
},
`<JobCommand><exec>command</exec></JobCommand>`,
},
marshalTest{
"with-script",
JobCommand{
Script: "script",
},
`<JobCommand><script>script</script></JobCommand>`,
},
marshalTest{
"with-script-interpreter",
JobCommand{
FileExtension: "sh",
Script: "Hello World!",
ScriptInterpreter: &JobCommandScriptInterpreter{
InvocationString: "sudo",
},
},
`<JobCommand><fileExtension>sh</fileExtension><script>Hello World!</script><scriptinterpreter>sudo</scriptinterpreter></JobCommand>`,
},
})
}
func TestUnmarshalJobCommand(t *testing.T) {
testUnmarshalXML(t, []unmarshalTest{
unmarshalTest{
"with-shell",
`<JobCommand><exec>command</exec></JobCommand>`,
&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><script>script</script></JobCommand>`,
&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",
`<JobCommand><script>Hello World!</script><fileExtension>sh</fileExtension><scriptinterpreter>sudo</scriptinterpreter></JobCommand>`,
&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",
},
`<scriptinterpreter>sudo</scriptinterpreter>`,
},
marshalTest{
"with-script-interpreter-quoted",
JobCommandScriptInterpreter{
ArgsQuoted: true,
InvocationString: "sudo",
},
`<scriptinterpreter argsquoted="true">sudo</scriptinterpreter>`,
},
})
}
func TestUnmarshalScriptInterpreter(t *testing.T) {
testUnmarshalXML(t, []unmarshalTest{
unmarshalTest{
"with-script-interpreter",
`<scriptinterpreter>sudo</scriptinterpreter>`,
&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",
`<scriptinterpreter argsquoted="true">sudo</scriptinterpreter>`,
&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",
},
},
},
},
`<sequence keepgoing="true" strategy="step-first"><command><errorhandler keepgoingOnSuccess="true"><script>error_script</script></errorhandler><script>inline_script</script></command></sequence>`,
},
})
}
func TestMarshalJobOption(t *testing.T) {
testMarshalXML(t, []marshalTest{
marshalTest{
"with-option-basic",
JobOption{
Name: "basic",
},
`<option name="basic"></option>`,
},
marshalTest{
"with-option-multivalued",
JobOption{
Name: "Multivalued",
MultiValueDelimiter: "|",
RequirePredefinedChoice: true,
AllowsMultipleValues: true,
IsRequired: true,
ValueChoices: JobValueChoices([]string{"myValues"}),
},
`<option delimiter="|" enforcedvalues="true" multivalued="true" name="Multivalued" required="true" values="myValues"></option>`,
},
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",
},
`<option delimiter="|" enforcedvalues="true" multivalued="true" name="advanced" regex=".+" required="true" secure="true" storagePath="myKey" value="myValue" valueExposed="true" values="myValues" valuesUrl="myValuesUrl"></option>`,
},
})
}

6
vendor/vendor.json vendored
View File

@ -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",