diff --git a/.gitignore b/.gitignore index daf913b..0c9e329 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Folders _obj _test +.idea # Architecture specific extensions/prefixes *.[568vq] diff --git a/README.md b/README.md index 0314f9e..7a429a9 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,15 @@ This is DRAX, the [DC/OS](https://dcos.io) Resilience Automated Xenodiagnosis tool. It helps to test DC/OS deployments by applying a [Chaos Monkey](http://techblog.netflix.com/2012/07/chaos-monkey-released-into-wild.html)-inspired, proactive and invasive testing approach. -![DRAX logo](img/drax-logo.png) - Well, actually DRAX is a reverse acronym inspired by the Guardians of the Galaxy character Drax the Destroyer. - You might have heard of Netflix's [Chaos Monkey](http://techblog.netflix.com/2012/07/chaos-monkey-released-into-wild.html) or it's containerized [variant](https://medium.com/production-ready/chaos-monkey-for-fun-and-profit-87e2f343db31). Maybe you've seen a [gaming version](https://www.wehkamplabs.com/blog/2016/06/02/docker-and-zombies/) of it or stumbled upon a [lower-level species](http://probablyfine.co.uk/2016/05/30/announcing-byte-monkey/). In any case I assume you're somewhat familiar with chaos-based resilience testing. DRAX is a DC/OS-specific resilience testing tool that works mainly on the task-level. Future work may include node-level up to cluster-level. ## Installation and usage -Note that DRAX assumes a running [DC/OS 1.7](https://dcos.io/releases/1.7.0/) cluster. +Note that DRAX assumes a running [DC/OS 1.9](https://dcos.io/) cluster. ### Production @@ -28,9 +25,13 @@ Now you can (modulo the public node of your cluster) do the following: Content-Length: 10 Content-Type: application/javascript Date: Mon, 13 Jun 2016 14:39:11 GMT - + {"gone":0} +If you launched DRAX via Marathon, you can also trigger a POST to the /rampage continuously by deploying a DC/OS job. The example job is triggering the destruction every business hour from Monday till Friday: + + $ dcos job add metronome-drax.json + ### Testing and development Get DRAX and build from source: @@ -38,10 +39,11 @@ Get DRAX and build from source: $ go get github.com/dcos-labs/drax $ go build $ MARATHON_URL=http://localhost:8080 ./drax - INFO[0000] Using Marathon at http://localhost:8080 main=init + INFO[0000] This is DRAX in version 0.4.0 main=init + INFO[0000] Listening on port 7777 main=init INFO[0000] On destruction level 0 main=init + INFO[0000] Using Marathon at http://localhost:8080 main=init INFO[0000] I will destroy 2 tasks on a rampage main=init - INFO[0000] This is DRAX in version 0.3.0 listening on port 7777 And in a different terminal session: @@ -50,7 +52,7 @@ And in a different terminal session: Content-Length: 10 Content-Type: application/javascript Date: Mon, 13 Jun 2016 14:39:11 GMT - + {"gone":0} For Go development, be aware of the following dependencies (not using explicit vendoring ATM): @@ -62,12 +64,6 @@ For Go development, be aware of the following dependencies (not using explicit v Note that the following environment variables are pre-set in the [Marathon app spec](marathon-drax.json) and yours to overwrite. -#### Destruction level - -You can influence the default destruction setting for DRAX via the env variable `DESTRUCTION_LEVEL`: - - 0 == destroy random tasks of any app - 1 == destroy random tasks of specific app #### Number of target tasks @@ -77,13 +73,6 @@ To specify how many tasks DRAX is supposed to destroy in one rampage, use `NUM_T To influence the log level, use the `LOG_LEVEL` env variable, for example `LOG_LEVEL=DEBUG drax` would give you fine-grained log messages (defaults to `INFO`). -### Roadmap - -- add seeds (hello world dummy, NGINX, Marvin): shell script + DC/OS CLI and walkthrough examples -- Weave [Scope](https://www.weave.works/products/weave-scope/) demo -- tests, tutorial, blog post -- node/cluster level rampages - ## API ### /health [GET] @@ -92,50 +81,24 @@ Will return a HTTP 200 code and `I am Groot` if DRAX is healthy. ### /stats [GET] -Will return runtime statistics, such as killed containers or apps over a report period specified with the `runs` parameter. For example, `/stats?runs=2` will report over the past two runs and if the `runs` parameter is not or wrongly specified it will report from the beginning of time (well, beginning of time for DRAX anyways). +Will return runtime statistics, such as killed containers or apps and will report from the beginning of time (well, beginning of time for DRAX anyways). $ http http://localhost:7777/stats HTTP/1.1 200 OK Content-Length: 10 Content-Type: application/javascript Date: Mon, 13 Jun 2016 14:39:11 GMT - + {"gone":2} ### /rampage [POST] -Will trigger a destruction run on a certain destruction level (see also configuration section above for the default value). - -#### Target any (non-framework) app - -To target any non-framework app, set the level of destruction (using the `level` parameter) to `0`, for example, `/rampage?level=0` will destroy random tasks of any apps. - -Invoke with default level (any tasks in any app): +Will trigger a destruction. Invoke with: $ http POST localhost:7777/rampage HTTP/1.1 200 OK Content-Length: 121 Content-Type: application/javascript Date: Mon, 13 Jun 2016 12:15:19 GMT - - {"success":true,"goners":["webserver.0fde0035-315f-11e6-aad0-1e9bbbc1653f","dummy.11a7c3bb-315f-11e6-aad0-1e9bbbc1653f"]} - -#### Target a specific (non-framework) app - -To target a specific (non-framework) app, set the level of destruction to `1` and specify the Marathon app id using the the `app` parameter. For example, `/rampage?level=1&app=dummy` will destroy random tasks of the app with the Marathon ID `/dummy`. - -Invoke like so (to destroy tasks of app `/dummy`): - - $ cat rp.json - { - "level" : "1", - "app" : "dummy" - } - $ http POST localhost:7777/rampage < rp.json - HTTP/1.1 200 OK - Content-Length: 117 - Content-Type: application/javascript - Date: Mon, 13 Jun 2016 13:05:31 GMT - - {"success":true,"goners":["dummy.59dca877-3165-11e6-aad0-1e9bbbc1653f","dummy.e96ffce3-3164-11e6-aad0-1e9bbbc1653f"]} + {"success":true,"goners":["webserver.0fde0035-315f-11e6-aad0-1e9bbbc1653f","dummy.11a7c3bb-315f-11e6-aad0-1e9bbbc1653f"]} diff --git a/api.go b/api.go index 60cc5aa..4c9f82c 100644 --- a/api.go +++ b/api.go @@ -2,111 +2,73 @@ package main import ( "encoding/json" - "fmt" - log "github.com/Sirupsen/logrus" - // "io/ioutil" - marathon "github.com/gambol99/go-marathon" + "io" "math/rand" "net/http" - "strconv" "strings" "sync/atomic" -) + "time" -// API nouns -type NOUN_Stats struct{} -type NOUN_Rampage struct{} + log "github.com/Sirupsen/logrus" + marathon "github.com/gambol99/go-marathon" +) -// JSON payloads -type RampageParams struct { - Level string `json:"level"` - AppID string `json:"app"` -} +// StatsResult JSON payload type StatsResult struct { - TasksKilled uint64 `json:"gone"` + KilledTasks uint64 `json:"gone"` } + +// RampageResult JSON payload type RampageResult struct { - Success bool `json:"success"` - TaskList []string `json:"goners"` + Success bool `json:"success"` + KilledTasks []string `json:"goners"` +} + +// Handles /health API calls (GET only) +func getHealth(w http.ResponseWriter, r *http.Request) { + log.WithFields(log.Fields{"handle": "/health"}).Info("health check") + io.WriteString(w, "I am Groot") } // Handles /stats API calls (GET only) -func (n NOUN_Stats) ServeHTTP(w http.ResponseWriter, r *http.Request) { - log.WithFields(log.Fields{"handle": "/stats"}).Info("Reporting on runtime statistics ...") - // extract $RUNS parameter from /stats?runs=$RUNS in the following: - runsParam := r.URL.Query().Get("runs") - log.WithFields(log.Fields{"handle": "/stats"}).Info("Runs param = ", runsParam) - if runs, err := strconv.Atoi(runsParam); err == nil && runs > 0 { - log.WithFields(log.Fields{"handle": "/stats"}).Info("... for the past ", runs, " run(s)") - } else { - log.WithFields(log.Fields{"handle": "/stats"}).Info("... from beginning of time") - } - sr := &StatsResult{} - sr.TasksKilled = atomic.LoadUint64(&overallTasksKilled) - jsonsr, _ := json.Marshal(sr) - w.Header().Set("Content-Type", "application/javascript") - fmt.Fprint(w, string(jsonsr)) +func getStats(w http.ResponseWriter, r *http.Request) { + log.WithFields(log.Fields{"handle": "/stats"}).Info("Overall tasks killed: ", overallTasksKilled) + + sr := &StatsResult{ + KilledTasks: overallTasksKilled} + + srJSON, _ := json.Marshal(sr) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, string(srJSON)) } // Handles /rampage API calls (POST only) -func (n NOUN_Rampage) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func postRampage(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { - err := r.ParseForm() - if err != nil { - http.Error(w, "Can't parse rampage params", 500) - } else { - ok, rp := parseRampageParams(r) - if !ok { - http.Error(w, "Can't decode rampage params", 500) - } - levelParam := rp.Level - if levelParam != "" { - log.WithFields(log.Fields{"handle": "/rampage"}).Info("Got level param ", levelParam) - if level, err := strconv.Atoi(levelParam); err == nil { - destructionLevel = DestructionLevel(level) - } - } - log.WithFields(log.Fields{"handle": "/rampage"}).Info("Starting rampage on destruction level ", destructionLevel) - switch destructionLevel { - case DL_BASIC: - killTasks(w, r) - case DL_ADVANCED: - appParam := rp.AppID - if appParam != "" { - log.WithFields(log.Fields{"handle": "/rampage"}).Info("Got app param ", appParam) - killTasksOfApp(w, r, appParam) - } else { - http.NotFound(w, r) - } - case DL_ALL: - fmt.Fprint(w, "not yet implemented") - default: - http.NotFound(w, r) - } + log.WithFields(log.Fields{"handle": "/rampage"}).Info("Starting rampage on destruction level ", destructionLevel) + + switch destructionLevel { + case DLBASIC: + killTasks(w, r) + case DLADVANCED: + io.WriteString(w, "not yet implemented") + case DLALL: + io.WriteString(w, "not yet implemented") + default: + http.NotFound(w, r) } + } else { log.WithFields(log.Fields{"handle": "/rampage"}).Error("Only POST method supported") http.NotFound(w, r) } -} -// parseRampageParams parses the parameters for a rampage from an HTTP request -func parseRampageParams(r *http.Request) (bool, *RampageParams) { - decoder := json.NewDecoder(r.Body) - rp := &RampageParams{} - err := decoder.Decode(rp) - if err != nil { - return false, nil - } else { - return true, rp - } } // killTasks will identify tasks of any apps (but not framework services) // and randomly kill off a few of them func killTasks(w http.ResponseWriter, r *http.Request) { if client, ok := getClient(); ok { - nonFrameworkApps := 0 apps, err := client.Applications(nil) if err != nil { log.WithFields(log.Fields{"handle": "/rampage"}).Info("Failed to list apps") @@ -114,12 +76,13 @@ func killTasks(w http.ResponseWriter, r *http.Request) { return } log.WithFields(log.Fields{"handle": "/rampage"}).Info("Found overall ", len(apps.Apps), " applications running") + candidates := []string{} for _, app := range apps.Apps { log.WithFields(log.Fields{"handle": "/rampage"}).Debug("APP ", app.ID) details, _ := client.Application(app.ID) + if !myself(details) && !isFramework(details) { - nonFrameworkApps++ if details.Tasks != nil && len(details.Tasks) > 0 { for _, task := range details.Tasks { log.WithFields(log.Fields{"handle": "/rampage"}).Debug("TASK ", task.ID) @@ -128,62 +91,58 @@ func killTasks(w http.ResponseWriter, r *http.Request) { } } } - rampage(w, client, nonFrameworkApps, candidates) + + if len(candidates) > 0 { + log.WithFields(log.Fields{"handle": "/rampage"}).Info("Found ", len(candidates), " tasks to kill") + rampage(w, client, candidates) + } + } else { http.Error(w, "Can't connect to Marathon", 500) } } -// killTasks will identify tasks of a specific app defined by targetAppID -// and randomly kill off a few of them -func killTasksOfApp(w http.ResponseWriter, r *http.Request, targetAppID string) { - if client, ok := getClient(); ok { - candidates := []string{} - details, _ := client.Application(targetAppID) - log.WithFields(log.Fields{"handle": "/rampage"}).Info("Found app ", details.ID, " running") - if !myself(details) && !isFramework(details) { - if details.Tasks != nil && len(details.Tasks) > 0 { - for _, task := range details.Tasks { - log.WithFields(log.Fields{"handle": "/rampage"}).Debug("TASK ", task.ID) - candidates = append(candidates, task.ID) - } - } - } - rampage(w, client, 1, candidates) - } else { - http.Error(w, "Can't connect to Marathon", 500) +// getClient tries to get a connection to the DC/OS System Marathon +func getClient() (marathon.Marathon, bool) { + config := marathon.NewDefaultConfig() + config.URL = marathonURL + client, err := marathon.NewClient(config) + if err != nil { + log.WithFields(log.Fields{"handle": "/rampage"}).Error("Failed to create Marathon client due to ", err) + return nil, false } + return client, true } // rampage kills random tasks from the candidates and returns a JSON result -func rampage(w http.ResponseWriter, c marathon.Marathon, numApps int, candidates []string) { - rr := &RampageResult{} - rr.TaskList = []string{} - targets := []int{} - if len(candidates) > 0 { - log.WithFields(log.Fields{"handle": "/rampage"}).Info("Found ", len(candidates), " tasks in ", numApps, " apps to kill") - // generates a list of random, non-repeating indices into the candidates: - if len(candidates) > numTargets { - targets = rand.Perm(len(candidates))[:numTargets] - } else { - targets = rand.Perm(len(candidates)) - } - for _, t := range targets { - candidate := candidates[t] - rr.Success = killTask(c, candidate) - if rr.Success { - rr.TaskList = append(rr.TaskList, candidate) - } - } - log.WithFields(log.Fields{"handle": "/rampage"}).Info("Killed tasks ", rr.TaskList) - // at least killed some tasks, so consider it a success: - rr.Success = true +func rampage(w http.ResponseWriter, c marathon.Marathon, candidates []string) { + var targets []int + + // generates a list of random, non-repeating indices into the candidates: + if len(candidates) > numTargets { + targets = rand.Perm(len(candidates))[:numTargets] } else { - rr.Success = false + targets = rand.Perm(len(candidates)) } - jsonrr, _ := json.Marshal(rr) - w.Header().Set("Content-Type", "application/javascript") - fmt.Fprint(w, string(jsonrr)) + + rr := &RampageResult{ + Success: true, + KilledTasks: []string{}} + + // kill the candidates + for _, t := range targets { + candidate := candidates[t] + killTask(c, candidate) + log.WithFields(log.Fields{"handle": "/rampage"}).Info("Killed tasks ", candidate) + rr.KilledTasks = append(rr.KilledTasks, candidate) + atomic.AddUint64(&overallTasksKilled, 1) + log.WithFields(log.Fields{"handle": "/rampage"}).Info("Counter: ", overallTasksKilled) + time.Sleep(time.Millisecond * time.Duration(sleepTime)) + } + + rrJSON, _ := json.Marshal(rr) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, string(rrJSON)) } // killTask kills a certain task and increments overall count if successful @@ -192,20 +151,18 @@ func killTask(c marathon.Marathon, taskID string) bool { if err != nil { log.WithFields(log.Fields{"handle": "/rampage"}).Debug("Not able to kill task ", taskID) return false - } else { - log.WithFields(log.Fields{"handle": "/rampage"}).Debug("Killed task ", taskID) - go incTasksKilled() - return true } + + log.WithFields(log.Fields{"handle": "/rampage"}).Debug("Killed task ", taskID) + return true } // myself returns true if it is applied to DRAX Marathon app itself func myself(app *marathon.Application) bool { if strings.Contains(app.ID, "drax") { return true - } else { - return false } + return false } // isFramework returns true if the Marathon app is a service framework, @@ -219,20 +176,3 @@ func isFramework(app *marathon.Application) bool { } return false } - -// getClient tries to get a connection to the DC/OS System Marathon -func getClient() (marathon.Marathon, bool) { - config := marathon.NewDefaultConfig() - config.URL = marathonURL - client, err := marathon.NewClient(config) - if err != nil { - log.WithFields(log.Fields{"handle": "/rampage"}).Error("Failed to create Marathon client due to ", err) - return nil, false - } - return client, true -} - -// incTasksKilled increases the overall tasks killed counter in an atomic way -func incTasksKilled() { - atomic.AddUint64(&overallTasksKilled, 1) -} diff --git a/examples/seed.sh b/examples/seed.sh index e864036..47d80ac 100755 --- a/examples/seed.sh +++ b/examples/seed.sh @@ -7,10 +7,10 @@ set -o errexit set -o pipefail set -o nounset -command -v dcos >/dev/null 2>&1 || { echo >&2 "Need the DC/OS CLI to carry out my work, but it seems it's not installed. See https://dcos.io/docs/1.7/usage/cli/install/ for how to get it ..."; exit 1; } +command -v dcos >/dev/null 2>&1 || { echo >&2 "Need the DC/OS CLI to carry out my work, but it seems it's not installed. See https://dcos.io/docs/latest/cli/install/ for how to get it ..."; exit 1; } for ma in *.json ; do echo "Trying to launching Marathon app defined in: $ma" dcos marathon app add $ma echo "$ma launched" -done \ No newline at end of file +done diff --git a/img/drax-logo.png b/img/drax-logo.png deleted file mode 100644 index 9208633..0000000 Binary files a/img/drax-logo.png and /dev/null differ diff --git a/main.go b/main.go index 789c18f..4877f37 100644 --- a/main.go +++ b/main.go @@ -1,85 +1,107 @@ package main import ( - "fmt" - log "github.com/Sirupsen/logrus" "net/http" "os" "strconv" "strings" + + log "github.com/Sirupsen/logrus" ) -// destruction level type (0 .. 2) +// DestructionLevel of type (0 .. 2) type DestructionLevel int const ( - // DRAX version - VERSION string = "0.3.0" - // The IP port DRAX is listening on - DRAX_PORT int = 7777 - // The number of tasks to kill - DEFAULT_NUM_TARGETS int = 2 + // VERSION of DRAX + VERSION string = "0.4.0" + // DEFAULTPORT is the port DRAX is listening on + DEFAULTPORT string = "7777" + // MARATHONURL for connection to DC/OS + MARATHONURL string = "http://marathon.mesos:8080" + // DEFAULTNUMTARGET is the number of tasks to kill + DEFAULTNUMTARGET int = 2 + // DEFAULTSLEEPTIME is the time in ms to wait between the killing of tasks + DEFAULTSLEEPTIME int = 100 ) const ( - // DL_BASIC means destroy random tasks - DL_BASIC DestructionLevel = iota - // DL_ADVANCED means destroy random apps - DL_ADVANCED - // DL_ALL means destroy random apps and services - DL_ALL + // DLBASIC means destroy random tasks + DLBASIC DestructionLevel = iota + // DLADVANCED means destroy random apps + DLADVANCED + // DLALL means destroy random apps and services + DLALL ) var ( - mux *http.ServeMux + port string marathonURL string - destructionLevel DestructionLevel = DL_BASIC - numTargets int = DEFAULT_NUM_TARGETS + destructionLevel = DestructionLevel(DLBASIC) + numTargets = int(DEFAULTNUMTARGET) + sleepTime = int(DEFAULTSLEEPTIME) overallTasksKilled uint64 ) func init() { - mux = http.NewServeMux() - // per default, use the cluster-internal, non-auth endpoint: - marathonURL = "http://marathon.mesos:8080" - if murl := os.Getenv("MARATHON_URL"); murl != "" { - marathonURL = murl + if ll := os.Getenv("LOG_LEVEL"); ll != "" { + switch strings.ToUpper(ll) { + case "DEBUG": + log.SetLevel(log.DebugLevel) + case "INFO": + log.SetLevel(log.InfoLevel) + default: + log.SetLevel(log.ErrorLevel) + } + } - log.WithFields(log.Fields{"main": "init"}).Info("Using Marathon at ", marathonURL) + log.WithFields(log.Fields{"main": "init"}).Info("This is DRAX in version ", VERSION) + + // set port for http server + if port = os.Getenv("PORT"); len(port) == 0 { + port = DEFAULTPORT + } + log.WithFields(log.Fields{"main": "init"}).Info("Listening on port ", port) + + // set destruction level if dl := os.Getenv("DESTRUCTION_LEVEL"); dl != "" { l, _ := strconv.Atoi(dl) destructionLevel = DestructionLevel(l) } log.WithFields(log.Fields{"main": "init"}).Info("On destruction level ", destructionLevel) + // per default, use the cluster-internal, non-auth endpoint: + if marathonURL = os.Getenv("MARATHON_URL"); marathonURL == "" { + marathonURL = MARATHONURL + } + log.WithFields(log.Fields{"main": "init"}).Info("Using Marathon at ", marathonURL) + if nt := os.Getenv("NUM_TARGETS"); nt != "" { n, _ := strconv.Atoi(nt) numTargets = n } log.WithFields(log.Fields{"main": "init"}).Info("I will destroy ", numTargets, " tasks on a rampage") - if ll := os.Getenv("LOG_LEVEL"); ll != "" { - switch strings.ToUpper(ll) { - case "DEBUG": - log.SetLevel(log.DebugLevel) - case "INFO": - log.SetLevel(log.InfoLevel) - default: - log.SetLevel(log.ErrorLevel) - } + if st := os.Getenv("SLEEP_TIME"); st != "" { + s, _ := strconv.Atoi(st) + sleepTime = s } + log.WithFields(log.Fields{"main": "init"}).Info("I will wait ", sleepTime, "ms between the killing of tasks") + } func main() { - log.Info("This is DRAX in version ", VERSION, " listening on port ", DRAX_PORT) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - log.WithFields(log.Fields{"handle": "/health"}).Info("health check") - fmt.Fprint(w, "I am Groot") - }) - mux.Handle("/stats", new(NOUN_Stats)) - mux.Handle("/rampage", new(NOUN_Rampage)) - p := strconv.Itoa(DRAX_PORT) - log.Fatal(http.ListenAndServe(":"+p, mux)) + + http.HandleFunc("/health", getHealth) + http.HandleFunc("/stats", getStats) + http.HandleFunc("/rampage", postRampage) + + err := http.ListenAndServe(":"+port, nil) + if err != nil { + log.Fatal(err) + panic(err) + } + } diff --git a/marathon-drax.json b/marathon-drax.json index 71e212b..f7a1374 100755 --- a/marathon-drax.json +++ b/marathon-drax.json @@ -1,20 +1,38 @@ { - "id": "drax", + "id": "/drax", "cmd": "chmod u+x drax && ./drax", + "instances": 1, "cpus": 0.1, "mem": 200, - "ports": [ - 0 + "fetch": [ + { + "uri": "https://github.com/dcos-labs/drax/releases/download/0.4.0/drax" + } ], - "uris": [ - "https://github.com/dcos-labs/drax/releases/download/0.3.0/drax" + "healthChecks": [ + { + "portIndex": 0, + "path": "/health", + "protocol": "MESOS_HTTP" + } ], + "acceptedResourceRoles": [ + "slave_public" + ], + "portDefinitions": [ + { + "port": 7777, + "protocol": "tcp", + "name": "default", + "labels": { + "VIP_0": "/drax:7777" + } + } + ], + "requirePorts": true, "env": { "LOG_LEVEL": "DEBUG", "DESTRUCTION_LEVEL": "0", "NUM_TARGETS": "3" - }, - "acceptedResourceRoles": [ - "slave_public" - ] -} \ No newline at end of file + } +} diff --git a/metronome-drax.json b/metronome-drax.json new file mode 100644 index 0000000..316f2b4 --- /dev/null +++ b/metronome-drax.json @@ -0,0 +1,20 @@ +{ + "id": "rampage", + "run": { + "cpus": 0.1, + "mem": 128, + "disk": 0, + "cmd": "http POST http://drax.marathon.l4lb.thisdcos.directory:7777/rampage", + "docker": { + "image": "clue/httpie" + } + }, + "schedules": [ + { + "id": "default", + "enabled": true, + "cron": "0 9-17 * * 1-5", + "concurrencyPolicy": "ALLOW" + } + ] +}