├── server ├── assets │ └── .gitkeep ├── static │ ├── images │ │ ├── favicon.ico │ │ ├── eremiteLOGO_01.png │ │ ├── eremiteLOGO_02.png │ │ ├── disappointed_minion.png │ │ ├── cpu.svg │ │ └── ram.svg │ ├── css │ │ ├── themes │ │ │ └── default │ │ │ │ └── assets │ │ │ │ └── fonts │ │ │ │ ├── icons.ttf │ │ │ │ ├── icons.woff │ │ │ │ ├── icons.woff2 │ │ │ │ ├── fontcustom_cb066050d5b786186aa0e7f121427d8b.eot │ │ │ │ ├── fontcustom_cb066050d5b786186aa0e7f121427d8b.ttf │ │ │ │ ├── fontcustom_cb066050d5b786186aa0e7f121427d8b.woff │ │ │ │ └── fontcustom_cb066050d5b786186aa0e7f121427d8b.svg │ │ └── style.css │ └── js │ │ ├── serialize_object.min.js │ │ └── task_view.js ├── generate.go ├── templates │ ├── error_401.html │ ├── error_404.html │ ├── index.html │ └── task.html ├── formatter_test.go ├── formatter.go ├── routes_test.go ├── routes_v0.go ├── routes_v1.go ├── routes.go ├── helpers_test.go ├── helpers.go ├── server_test.go └── handler.go ├── api ├── version.go ├── route.go ├── api_test.go ├── api_v0.go └── api_v1.go ├── dcos ├── icon-eremetic-large.png ├── icon-eremetic-medium.png ├── icon-eremetic-small.png ├── icon-eremetic-large@2x.png ├── icon-eremetic-medium@2x.png └── icon-eremetic-small@2x.png ├── .gitignore ├── version └── version.go ├── misc ├── docker.sh ├── eremetic.json └── swagger.yaml ├── docker ├── marathon.sh └── Dockerfile ├── eremetic.yml.example ├── scheduler.go ├── config ├── config_test.yml ├── config_test.go └── config.go ├── LICENSE ├── cmd ├── hermit │ └── README.md └── eremetic │ ├── eremetic_test.go │ └── eremetic.go ├── zk ├── zk_connector.go ├── zk_connection.go └── zookeeper.go ├── boltdb ├── bolt_connector.go ├── bolt_connection.go ├── boltdb.go └── boltdb_test.go ├── mesos ├── driver_test.go ├── extractor.go ├── builders.go ├── reconcile_test.go ├── extractor_test.go ├── reconcile.go ├── driver.go ├── match.go ├── match_test.go └── task.go ├── .travis.yml ├── callback.go ├── metrics └── metrics.go ├── go.mod ├── callback_test.go ├── Makefile ├── examples.md ├── database.go ├── mock ├── mock.go └── mesos_scheduler.go ├── client ├── client_test.go └── client.go └── README.md /server/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/version.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // API Version constants 4 | const ( 5 | V0 = "apiv0" 6 | V1 = "apiv1" 7 | ) 8 | -------------------------------------------------------------------------------- /dcos/icon-eremetic-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/dcos/icon-eremetic-large.png -------------------------------------------------------------------------------- /dcos/icon-eremetic-medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/dcos/icon-eremetic-medium.png -------------------------------------------------------------------------------- /dcos/icon-eremetic-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/dcos/icon-eremetic-small.png -------------------------------------------------------------------------------- /dcos/icon-eremetic-large@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/dcos/icon-eremetic-large@2x.png -------------------------------------------------------------------------------- /dcos/icon-eremetic-medium@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/dcos/icon-eremetic-medium@2x.png -------------------------------------------------------------------------------- /dcos/icon-eremetic-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/dcos/icon-eremetic-small@2x.png -------------------------------------------------------------------------------- /server/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/images/favicon.ico -------------------------------------------------------------------------------- /server/static/images/eremiteLOGO_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/images/eremiteLOGO_01.png -------------------------------------------------------------------------------- /server/static/images/eremiteLOGO_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/images/eremiteLOGO_02.png -------------------------------------------------------------------------------- /server/static/images/disappointed_minion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/images/disappointed_minion.png -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/css/themes/default/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/css/themes/default/assets/fonts/icons.woff -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/css/themes/default/assets/fonts/icons.woff2 -------------------------------------------------------------------------------- /server/generate.go: -------------------------------------------------------------------------------- 1 | //go:generate rm -vf assets/assets.go 2 | //go:generate go-bindata-assetfs -pkg assets -o assets/assets.go ./static/... ./templates/... 3 | 4 | package server 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | eremetic 2 | eremetic.yml 3 | db/*.db 4 | docker/static 5 | docker/templates 6 | server/assets/assets.go 7 | .DS_Store 8 | ._* 9 | *\.sw[op] 10 | vendor/* 11 | *.orig 12 | .idea 13 | -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.eot -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.ttf -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eremetic-framework/eremetic/HEAD/server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.woff -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | // Version contains the Eremetic application version. Overridden at compile-time by make 5 | Version = "0.0.0+devel" 6 | // BuildDate contains the date of the build 7 | BuildDate = "" 8 | ) 9 | -------------------------------------------------------------------------------- /server/templates/error_401.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 401 Unauthorized | Eremetic 6 | 7 | 8 | 401 Unauthorized 9 | 10 | -------------------------------------------------------------------------------- /misc/docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | test -n "$DOCKER_USERNAME" -a "$PUBLISH_VERSION" == "$TRAVIS_GO_VERSION" 6 | grep -E 'master|v[0-9.]+' > /dev/null <<< "$TRAVIS_BRANCH" 7 | 8 | docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" 9 | make docker 10 | make publish-docker 11 | -------------------------------------------------------------------------------- /api/route.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | 5 | // Route enforces the structure of a route 6 | type Route struct { 7 | Name string 8 | Method string 9 | Pattern string 10 | Handler http.Handler 11 | } 12 | 13 | // Routes is a collection of route structs 14 | type Routes []Route 15 | -------------------------------------------------------------------------------- /docker/marathon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | lookup_host() { 4 | nslookup "$1" | awk -v HOST="$1" '{ if ($2 == HOST) { getline; gsub(/^.*: /, ""); print $1 } }' 5 | } 6 | 7 | export MESSENGER_ADDRESS=`lookup_host ${HOST}${DOMAIN:+.$DOMAIN}` 8 | export MESSENGER_PORT=$PORT1 9 | 10 | 11 | exec /opt/eremetic/eremetic 12 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.10.0 2 | 3 | RUN apk update \ 4 | && apk upgrade \ 5 | && apk add --no-cache \ 6 | ca-certificates 7 | 8 | RUN mkdir -p /opt/eremetic 9 | 10 | COPY eremetic /opt/eremetic/eremetic 11 | COPY marathon.sh /marathon.sh 12 | 13 | WORKDIR /opt/eremetic 14 | CMD [ "/marathon.sh" ] 15 | -------------------------------------------------------------------------------- /eremetic.yml.example: -------------------------------------------------------------------------------- 1 | address: 0.0.0.0 2 | port: 8080 3 | master: zk://,,(...)/mesos 4 | messenger_address: 5 | messenger_port: 6 | loglevel: info 7 | logformat: json 8 | database: db/eremetic.db 9 | credential_file: /tmp/secret_file 10 | queue_size: 100 11 | -------------------------------------------------------------------------------- /server/formatter_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestFormatter(t *testing.T) { 11 | Convey("FormatTime", t, func() { 12 | Convey("A Valid Unix Timestamp", func() { 13 | t := time.Now().Unix() 14 | So(FormatTime(t), ShouldNotBeEmpty) 15 | }) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /server/formatter.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // FormatTime takes a UnixDate and transforms it into YYYY-mm-dd HH:MM:SS 9 | func FormatTime(unixTime int64) string { 10 | t := time.Unix(unixTime, 0) 11 | 12 | year, month, day := t.Date() 13 | 14 | return fmt.Sprintf("%d-%02d-%02d %02d:%02d:%02d", year, month, day, t.Hour(), t.Minute(), t.Second()) 15 | } 16 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package eremetic 2 | 3 | import "errors" 4 | 5 | // ErrQueueFull is returned in the event of a full queue. This allows the caller 6 | // to handle this as they see fit. 7 | var ErrQueueFull = errors.New("task queue is full") 8 | 9 | // Scheduler defines an interface for scheduling tasks. 10 | type Scheduler interface { 11 | ScheduleTask(request Request) (string, error) 12 | Kill(taskID string) error 13 | } 14 | -------------------------------------------------------------------------------- /config/config_test.yml: -------------------------------------------------------------------------------- 1 | address: 0.0.0.0 2 | port: 8080 3 | http_credentials: admin:admin 4 | master: zk://10.10.10.10:2182/mesos 5 | messenger_address: 6 | messenger_port: 7 | loglevel: info 8 | logformat: json 9 | database: db/eremetic.db 10 | credential_file: /tmp/secret_file 11 | queue_size: 100 12 | url_prefix: 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Klarna AB 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /misc/eremetic.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/eremetic", 3 | "cpus": 0.2, 4 | "mem": 100.0, 5 | "instances": 1, 6 | "container": { 7 | "type": "DOCKER", 8 | "docker": { 9 | "image": "alde/eremetic", 10 | "network": "BRIDGE", 11 | "forcePullImage": true, 12 | "privileged": true, 13 | "portMappings": [ 14 | { "containerPort": 8000, "hostPort": 0 }, 15 | { "containerPort": 0, "hostPort": 0 } 16 | ] 17 | } 18 | }, 19 | "env": { 20 | "MASTER": "zk://zoo1:2181,zoo2:2181/mesos", 21 | "ADDRESS": "0.0.0.0", 22 | "PORT": "8000" 23 | }, 24 | "labels": { 25 | "traefik.portIndex": "0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cmd/hermit/README.md: -------------------------------------------------------------------------------- 1 | # hermit 2 | 3 | A tool for running and listing Eremetic tasks from the command-line. 4 | 5 | ## Examples: 6 | 7 | Run an Eremetic task. 8 | 9 | hermit run -cpu 0.2 -mem 32 -image busybox echo hello 10 | 11 | List active Eremetic tasks. 12 | 13 | hermit ls 14 | 15 | Fetch information about a specific task. 16 | 17 | hermit task eremetic-task-id-abc123 18 | 19 | Fetch the logs of a task. 20 | 21 | hermit logs -file stderr eremetic-task-id-abc123 22 | 23 | ## Configuration 24 | 25 | You can configure hermit using these environment variables: 26 | 27 | - `EREMETIC_URL`: URL of Eremetic server to connect to. 28 | - `HERMIT_INSECURE`: Allow establishing insecure connections. 29 | -------------------------------------------------------------------------------- /zk/zk_connector.go: -------------------------------------------------------------------------------- 1 | package zk 2 | 3 | import "github.com/stretchr/testify/mock" 4 | 5 | // mockConnector is an autogenerated mock type for the connector type 6 | type mockConnector struct { 7 | mock.Mock 8 | } 9 | 10 | // Connect provides a mock function with given fields: path 11 | func (_m *mockConnector) Connect(path string) (connection, error) { 12 | ret := _m.Called(path) 13 | 14 | var r0 connection 15 | if rf, ok := ret.Get(0).(func(string) connection); ok { 16 | r0 = rf(path) 17 | } else { 18 | if ret.Get(0) != nil { 19 | r0 = ret.Get(0).(connection) 20 | } 21 | } 22 | 23 | var r1 error 24 | if rf, ok := ret.Get(1).(func(string) error); ok { 25 | r1 = rf(path) 26 | } else { 27 | r1 = ret.Error(1) 28 | } 29 | 30 | return r0, r1 31 | } 32 | -------------------------------------------------------------------------------- /boltdb/bolt_connector.go: -------------------------------------------------------------------------------- 1 | package boltdb 2 | 3 | import "github.com/stretchr/testify/mock" 4 | 5 | // mockConnector is an autogenerated mock type for the ConnectorInterface type 6 | type mockConnector struct { 7 | mock.Mock 8 | } 9 | 10 | // Open provides a mock function with given fields: path 11 | func (_m *mockConnector) Open(path string) (connection, error) { 12 | ret := _m.Called(path) 13 | 14 | var r0 connection 15 | if rf, ok := ret.Get(0).(func(string) connection); ok { 16 | r0 = rf(path) 17 | } else { 18 | if ret.Get(0) != nil { 19 | r0 = ret.Get(0).(connection) 20 | } 21 | } 22 | 23 | var r1 error 24 | if rf, ok := ret.Get(1).(func(string) error); ok { 25 | r1 = rf(path) 26 | } else { 27 | r1 = ret.Error(1) 28 | } 29 | 30 | return r0, r1 31 | } 32 | -------------------------------------------------------------------------------- /server/routes_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | 8 | "github.com/eremetic-framework/eremetic" 9 | "github.com/eremetic-framework/eremetic/config" 10 | ) 11 | 12 | func TestRoutes(t *testing.T) { 13 | routes := routes(Handler{}, &config.Config{}) 14 | 15 | db := eremetic.NewDefaultTaskDB() 16 | 17 | Convey("Create", t, func() { 18 | Convey("Should build the expected routes", func() { 19 | m := NewRouter(nil, &config.Config{}, db) 20 | for _, route := range routes { 21 | So(m.GetRoute(route.Name), ShouldNotBeNil) 22 | } 23 | }) 24 | }) 25 | 26 | Convey("Expected number of routes", t, func() { 27 | ExpectedNumberOfRoutes := 18 // Magic numbers FTW 28 | 29 | So(len(routes), ShouldEqual, ExpectedNumberOfRoutes) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /mesos/driver_test.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func TestDriver(t *testing.T) { 10 | Convey("createDriver", t, func() { 11 | Convey("Error when master URL can't be found", func() { 12 | scheduler := Scheduler{} 13 | 14 | driver, err := createDriver(&scheduler, &Settings{}) 15 | 16 | So(err.Error(), ShouldEqual, "Missing master location URL.") 17 | So(driver, ShouldBeNil) 18 | }) 19 | }) 20 | 21 | Convey("getFrameworkID", t, func() { 22 | Convey("Empty ID", func() { 23 | fid := getFrameworkID(&Scheduler{}) 24 | So(fid, ShouldBeNil) 25 | }) 26 | 27 | Convey("Some random string", func() { 28 | fid := getFrameworkID(&Scheduler{ 29 | frameworkID: "zoidberg", 30 | }) 31 | So(*fid.Value, ShouldEqual, "zoidberg") 32 | }) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.13 5 | 6 | env: 7 | - PUBLISH_VERSION=1.13 GO111MODULE=on 8 | 9 | install: true 10 | 11 | services: 12 | - docker 13 | 14 | before_install: 15 | - go get -u golang.org/x/lint/golint 16 | - go get -u github.com/go-playground/overalls 17 | - go get -u github.com/mattn/goveralls 18 | - curl https://bin.equinox.io/a/75VeNN6mcnk/github-com-kevinburke-go-bindata-go-bindata-linux-amd64.tar.gz | tar xfz - -C $GOPATH/bin/ 19 | - go get github.com/go-bindata/go-bindata/... 20 | - go get -u github.com/elazarl/go-bindata-assetfs/... 21 | - go get -u github.com/smartystreets/goconvey 22 | 23 | script: 24 | - make lint 25 | - make vet 26 | - make eremetic 27 | - overalls -project=github.com/eremetic-framework/eremetic -covermode=count 28 | - goveralls -coverprofile=overalls.coverprofile -service travis-ci 29 | 30 | after_success: 31 | - misc/docker.sh 32 | -------------------------------------------------------------------------------- /mesos/extractor.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | type mounts struct { 10 | Mounts []dockerMounts `json:"Mounts"` 11 | } 12 | 13 | type dockerMounts struct { 14 | Source string `json:"Source"` 15 | Destination string `json:"Destination"` 16 | Mode string `json:"Mode"` 17 | RW bool `json:"RW"` 18 | } 19 | 20 | func extractSandboxPath(statusData []byte) (string, error) { 21 | var mounts []mounts 22 | 23 | if len(statusData) == 0 { 24 | logrus.Debug("No Data in task status.") 25 | return "", nil 26 | } 27 | 28 | if err := json.Unmarshal(statusData, &mounts); err != nil { 29 | logrus.WithError(err).Error("Task status data contained invalid JSON.") 30 | return "", err 31 | } 32 | 33 | for _, m := range mounts { 34 | for _, dm := range m.Mounts { 35 | if dm.Destination == "/mnt/mesos/sandbox" { 36 | return dm.Source, nil 37 | } 38 | } 39 | } 40 | 41 | logrus.Debug("No sandbox mount found in task status data.") 42 | return "", nil 43 | } 44 | -------------------------------------------------------------------------------- /cmd/eremetic/eremetic_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | . "github.com/smartystreets/goconvey/convey" 8 | 9 | "github.com/eremetic-framework/eremetic/config" 10 | ) 11 | 12 | func TestMain(t *testing.T) { 13 | conf := config.DefaultConfig() 14 | Convey("GetSchedulerSettings", t, func() { 15 | Convey("Contains defaults", func() { 16 | s := getSchedulerSettings(conf) 17 | 18 | So(s.MaxQueueSize, ShouldEqual, 100) 19 | So(s.Master, ShouldEqual, "") 20 | So(s.FrameworkID, ShouldEqual, "") 21 | So(s.CredentialFile, ShouldEqual, "") 22 | So(s.Name, ShouldEqual, "Eremetic") 23 | So(s.User, ShouldEqual, "root") 24 | So(s.MessengerAddress, ShouldEqual, "") 25 | So(s.MessengerPort, ShouldEqual, 0) 26 | So(s.Checkpoint, ShouldEqual, true) 27 | So(s.FailoverTimeout, ShouldAlmostEqual, 2592000.0) 28 | }) 29 | }) 30 | 31 | Convey("setupLogging", t, func() { 32 | setupLogging(conf.LogFormat, conf.LogLevel) 33 | So(logrus.GetLevel(), ShouldEqual, logrus.DebugLevel) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /boltdb/bolt_connection.go: -------------------------------------------------------------------------------- 1 | package boltdb 2 | 3 | import ( 4 | "github.com/boltdb/bolt" 5 | "github.com/stretchr/testify/mock" 6 | ) 7 | 8 | // mockConnection is an autogenerated mock type for the Connection type 9 | type mockConnection struct { 10 | mock.Mock 11 | } 12 | 13 | // Close provides a mock function with given fields: 14 | func (_m *mockConnection) Close() error { 15 | ret := _m.Called() 16 | 17 | var r0 error 18 | if rf, ok := ret.Get(0).(func() error); ok { 19 | r0 = rf() 20 | } else { 21 | r0 = ret.Error(0) 22 | } 23 | 24 | return r0 25 | } 26 | 27 | // Update provides a mock function with given fields: _a0 28 | func (_m *mockConnection) Update(_a0 func(*bolt.Tx) error) error { 29 | ret := _m.Called(_a0) 30 | 31 | var r0 error 32 | if rf, ok := ret.Get(0).(func(func(*bolt.Tx) error) error); ok { 33 | r0 = rf(_a0) 34 | } else { 35 | r0 = ret.Error(0) 36 | } 37 | 38 | return r0 39 | } 40 | 41 | // View provides a mock function with given fields: _a0 42 | func (_m *mockConnection) View(_a0 func(*bolt.Tx) error) error { 43 | ret := _m.Called(_a0) 44 | 45 | var r0 error 46 | if rf, ok := ret.Get(0).(func(func(*bolt.Tx) error) error); ok { 47 | r0 = rf(_a0) 48 | } else { 49 | r0 = ret.Error(0) 50 | } 51 | 52 | return r0 53 | } 54 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestConfig(t *testing.T) { 12 | wd, _ := os.Getwd() 13 | 14 | Convey("The Config Builders", t, func() { 15 | conf := DefaultConfig() 16 | 17 | Convey("ReadConfigFile", func() { 18 | ReadConfigFile(conf, fmt.Sprintf("%s/config_test.yml", wd)) 19 | So(conf.Port, ShouldEqual, 8080) 20 | So(conf.Address, ShouldEqual, "0.0.0.0") 21 | So(conf.HTTPCredentials, ShouldEqual, "admin:admin") 22 | So(conf.CredentialsFile, ShouldEqual, "/tmp/secret_file") 23 | }) 24 | 25 | Convey("ReadEnvironment", func() { 26 | master := "zk://local.host:2182/mesos" 27 | dbPath := "db/eremetic.db" 28 | frameworkID := "a_framework_id" 29 | httpCredentials := "admin:admin" 30 | urlPrefix := "/service/eremetic" 31 | 32 | os.Setenv("MASTER", master) 33 | os.Setenv("DATABASE", dbPath) 34 | os.Setenv("FRAMEWORK_ID", frameworkID) 35 | os.Setenv("HTTP_CREDENTIALS", httpCredentials) 36 | os.Setenv("URL_PREFIX", urlPrefix) 37 | 38 | ReadEnvironment(conf) 39 | 40 | So(conf.Master, ShouldEqual, master) 41 | So(conf.DatabasePath, ShouldEqual, dbPath) 42 | So(conf.FrameworkID, ShouldEqual, frameworkID) 43 | So(conf.HTTPCredentials, ShouldEqual, httpCredentials) 44 | So(conf.URLPrefix, ShouldEqual, urlPrefix) 45 | }) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /server/templates/error_404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 404 Not Found | Eremetic 11 | 12 | 13 | 20 |
21 |
22 | 23 |
24 |
25 | {{if .TaskID}} 26 | Could not find {{.TaskID}} 27 | {{else}} 28 | Whaaaa? 29 | {{end}} 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /server/routes_v0.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/eremetic-framework/eremetic/api" 5 | "github.com/eremetic-framework/eremetic/config" 6 | ) 7 | 8 | func apiV0Routes(h Handler, conf *config.Config) Routes { 9 | return Routes{ 10 | Route{ 11 | Name: "AddTask", 12 | Method: "POST", 13 | Pattern: "/task", 14 | Handler: h.AddTask(conf, api.V0), 15 | }, 16 | Route{ 17 | Name: "Status", 18 | Method: "GET", 19 | Pattern: "/task/{taskId}", 20 | Handler: h.GetTaskInfo(conf, api.V0), 21 | }, 22 | Route{ 23 | Name: "STDOUT", 24 | Method: "GET", 25 | Pattern: "/task/{taskId}/stdout", 26 | Handler: h.GetFromSandbox("stdout", api.V0), 27 | }, 28 | Route{ 29 | Name: "STDERR", 30 | Method: "GET", 31 | Pattern: "/task/{taskId}/stderr", 32 | Handler: h.GetFromSandbox("stderr", api.V0), 33 | }, 34 | Route{ 35 | Name: "Kill", 36 | Method: "POST", 37 | Pattern: "/task/{taskId}/kill", 38 | Handler: h.KillTask(conf, api.V0), 39 | }, 40 | Route{ 41 | Name: "Delete", 42 | Method: "DELETE", 43 | Pattern: "/task/{taskId}", 44 | Handler: h.DeleteTask(conf, api.V0), 45 | }, 46 | Route{ 47 | Name: "ListRunningTasks", 48 | Method: "GET", 49 | Pattern: "/task", 50 | Handler: h.ListTasks(api.V0), 51 | }, 52 | Route{ 53 | Name: "Version", 54 | Method: "GET", 55 | Pattern: "/version", 56 | Handler: h.Version(conf, api.V0), 57 | }, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/routes_v1.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/eremetic-framework/eremetic/api" 5 | "github.com/eremetic-framework/eremetic/config" 6 | ) 7 | 8 | func apiV1Routes(h Handler, conf *config.Config) Routes { 9 | return Routes{ 10 | Route{ 11 | Name: "AddTask", 12 | Method: "POST", 13 | Pattern: "/api/v1/task", 14 | Handler: h.AddTask(conf, api.V1), 15 | }, 16 | Route{ 17 | Name: "Status", 18 | Method: "GET", 19 | Pattern: "/api/v1/task/{taskId}", 20 | Handler: h.GetTaskInfo(conf, api.V1), 21 | }, 22 | Route{ 23 | Name: "STDOUT", 24 | Method: "GET", 25 | Pattern: "/api/v1/task/{taskId}/stdout", 26 | Handler: h.GetFromSandbox("stdout", api.V1), 27 | }, 28 | Route{ 29 | Name: "STDERR", 30 | Method: "GET", 31 | Pattern: "/api/v1/task/{taskId}/stderr", 32 | Handler: h.GetFromSandbox("stderr", api.V1), 33 | }, 34 | Route{ 35 | Name: "Kill", 36 | Method: "POST", 37 | Pattern: "/api/v1/task/{taskId}/kill", 38 | Handler: h.KillTask(conf, api.V1), 39 | }, 40 | Route{ 41 | Name: "Delete", 42 | Method: "DELETE", 43 | Pattern: "/api/v1/task/{taskId}", 44 | Handler: h.DeleteTask(conf, api.V1), 45 | }, 46 | Route{ 47 | Name: "ListRunningTasks", 48 | Method: "GET", 49 | Pattern: "/api/v1/task", 50 | Handler: h.ListTasks(api.V1), 51 | }, 52 | Route{ 53 | Name: "Version", 54 | Method: "GET", 55 | Pattern: "/api/v1/version", 56 | Handler: h.Version(conf, api.V1), 57 | }, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /callback.go: -------------------------------------------------------------------------------- 1 | package eremetic 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // CallbackData holds information about the status update. 12 | type CallbackData struct { 13 | Time int64 `json:"time"` 14 | Status string `json:"status"` 15 | TaskID string `json:"task_id"` 16 | } 17 | 18 | // NotifyCallback handles posting a JSON back to the URI given with the task. 19 | func NotifyCallback(task *Task) { 20 | if len(task.CallbackURI) == 0 { 21 | return 22 | } 23 | 24 | if len(task.Status) == 0 { 25 | return 26 | } 27 | 28 | status := task.Status[len(task.Status)-1] 29 | 30 | data := CallbackData{ 31 | Time: status.Time, 32 | Status: status.Status.String(), 33 | TaskID: task.ID, 34 | } 35 | 36 | body, err := json.Marshal(data) 37 | if err != nil { 38 | logrus.WithError(err).WithFields(logrus.Fields{ 39 | "task_id": task.ID, 40 | "callback_uri": task.CallbackURI, 41 | }).Error("Unable to create callback message") 42 | 43 | return 44 | } 45 | 46 | go func() { 47 | _, err = http.Post(task.CallbackURI, "application/json", bytes.NewBuffer(body)) 48 | if err != nil { 49 | logrus.WithError(err).WithFields(logrus.Fields{ 50 | "task_id": task.ID, 51 | "callback_uri": task.CallbackURI, 52 | }).Error("Unable to POST to Callback URI") 53 | 54 | return 55 | } 56 | 57 | logrus.WithFields(logrus.Fields{ 58 | "task_id": task.ID, 59 | "callback_uri": task.CallbackURI, 60 | }).Debug("Sent callback") 61 | }() 62 | } 63 | -------------------------------------------------------------------------------- /server/static/js/serialize_object.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jQuery serializeObject 3 | * @copyright 2014, macek 4 | * @link https://github.com/macek/jquery-serialize-object 5 | * @license BSD 6 | * @version 2.5.0 7 | */ 8 | !function(e,i){if("function"==typeof define&&define.amd)define(["exports","jquery"],function(e,r){return i(e,r)});else if("undefined"!=typeof exports){var r=require("jquery");i(exports,r)}else i(e,e.jQuery||e.Zepto||e.ender||e.$)}(this,function(e,i){function r(e,r){function n(e,i,r){return e[i]=r,e}function a(e,i){for(var r,a=e.match(t.key);void 0!==(r=a.pop());)if(t.push.test(r)){var u=s(e.replace(/\[\]$/,""));i=n([],u,i)}else t.fixed.test(r)?i=n([],r,i):t.named.test(r)&&(i=n({},r,i));return i}function s(e){return void 0===h[e]&&(h[e]=0),h[e]++}function u(e){switch(i('[name="'+e.name+'"]',r).attr("type")){case"checkbox":return"on"===e.value?!0:e.value;default:return e.value}}function f(i){if(!t.validate.test(i.name))return this;var r=a(i.name,u(i));return l=e.extend(!0,l,r),this}function d(i){if(!e.isArray(i))throw new Error("formSerializer.addPairs expects an Array");for(var r=0,t=i.length;t>r;r++)this.addPair(i[r]);return this}function o(){return l}function c(){return JSON.stringify(o())}var l={},h={};this.addPair=f,this.addPairs=d,this.serialize=o,this.serializeJSON=c}var t={validate:/^[a-z_][a-z0-9_]*(?:\[(?:\d*|[a-z0-9_]+)\])*$/i,key:/[a-z0-9_]+|(?=\[\])/gi,push:/^$/,fixed:/^\d+$/,named:/^[a-z0-9_]+$/i};return r.patterns=t,r.serializeObject=function(){return new r(i,this).addPairs(this.serializeArray()).serialize()},r.serializeJSON=function(){return new r(i,this).addPairs(this.serializeArray()).serializeJSON()},"undefined"!=typeof i.fn&&(i.fn.serializeObject=r.serializeObject,i.fn.serializeJSON=r.serializeJSON),e.FormSerializer=r,r}); 9 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/eremetic-framework/eremetic" 8 | ) 9 | 10 | var task = eremetic.Task{ 11 | TaskCPUs: 12, 12 | TaskMem: 255, 13 | Command: "task.Command", 14 | Args: []string{"task.Args"}, 15 | User: "task.User", 16 | Environment: map[string]string{"key": "value"}, 17 | MaskedEnvironment: map[string]string{"key2": "masked_value"}, 18 | Labels: map[string]string{"label1": "label_value"}, 19 | Image: "task.Image", 20 | Volumes: []eremetic.Volume{}, 21 | Ports: []eremetic.Port{}, 22 | Status: []eremetic.Status{eremetic.Status{Status: eremetic.TaskFinished, Time: 0}}, 23 | ID: "task.ID", 24 | Name: "task.Name", 25 | Network: "task.Network", 26 | DNS: "task.DNS", 27 | FrameworkID: "task.FrameworkID", 28 | AgentID: "task.AgentID", 29 | AgentConstraints: []eremetic.AgentConstraint{}, 30 | Hostname: "task.Hostname", 31 | Retry: 5, 32 | CallbackURI: "task.CallbackURI", 33 | SandboxPath: "task.SandboxPath", 34 | AgentIP: "task.AgentIP", 35 | AgentPort: 1234, 36 | ForcePullImage: true, 37 | Privileged: false, 38 | FetchURIs: []eremetic.URI{}, 39 | } 40 | 41 | func TestAPI_V0_TaskV0FromTask_TaskFromV0(t *testing.T) { 42 | t0 := TaskV0FromTask(&task) 43 | ta := TaskFromV0(&t0) 44 | if !reflect.DeepEqual(ta, task) { 45 | t.Fatalf("Invalid conversion.\nExpected:\t%+v\nActual:\t%+v", ta, task) 46 | } 47 | } 48 | 49 | func TestAPI_V1_TaskV1FromTask_TaskFromV1(t *testing.T) { 50 | t1 := TaskV1FromTask(&task) 51 | ta := TaskFromV1(&t1) 52 | if !reflect.DeepEqual(ta, task) { 53 | t.Fatalf("Invalid conversion.\nExpected:\t%+v\nActual:\t%+v", ta, task) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /mesos/builders.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "github.com/golang/protobuf/proto" 5 | "github.com/mesos/mesos-go/api/v0/mesosproto" 6 | "github.com/mesos/mesos-go/api/v0/mesosutil" 7 | ) 8 | 9 | // Optional attributes can be added. 10 | func offer(id string, cpu float64, mem float64, unavailability *mesosproto.Unavailability, extra ...interface{}) *mesosproto.Offer { 11 | attributes := []*mesosproto.Attribute{} 12 | resources := []*mesosproto.Resource{ 13 | mesosutil.NewScalarResource("cpus", cpu), 14 | mesosutil.NewScalarResource("mem", mem), 15 | } 16 | for _, r := range extra { 17 | switch r.(type) { 18 | case *mesosproto.Attribute: 19 | attributes = append(attributes, r.(*mesosproto.Attribute)) 20 | case *mesosproto.Resource: 21 | resources = append(resources, r.(*mesosproto.Resource)) 22 | } 23 | } 24 | return &mesosproto.Offer{ 25 | Id: &mesosproto.OfferID{ 26 | Value: proto.String(id), 27 | }, 28 | FrameworkId: &mesosproto.FrameworkID{ 29 | Value: proto.String("framework-1234"), 30 | }, 31 | SlaveId: &mesosproto.SlaveID{ 32 | Value: proto.String("agent-id"), 33 | }, 34 | Hostname: proto.String("localhost"), 35 | Resources: resources, 36 | Attributes: attributes, 37 | Unavailability: unavailability, 38 | } 39 | } 40 | 41 | func textAttribute(name string, value string) *mesosproto.Attribute { 42 | return &mesosproto.Attribute{ 43 | Name: proto.String(name), 44 | Type: mesosproto.Value_TEXT.Enum(), 45 | Text: &mesosproto.Value_Text{ 46 | Value: proto.String(value), 47 | }, 48 | } 49 | } 50 | 51 | func unavailability(details ...int64) *mesosproto.Unavailability { 52 | un := mesosproto.Unavailability{} 53 | if len(details) >= 1 { 54 | un.Start = &mesosproto.TimeInfo{ 55 | Nanoseconds: proto.Int64(details[0]), 56 | } 57 | } 58 | if len(details) >= 2 { 59 | un.Duration = &mesosproto.DurationInfo{ 60 | Nanoseconds: proto.Int64(details[1]), 61 | } 62 | } 63 | return &un 64 | } 65 | -------------------------------------------------------------------------------- /server/routes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/prometheus/client_golang/prometheus" 8 | 9 | "github.com/eremetic-framework/eremetic" 10 | "github.com/eremetic-framework/eremetic/config" 11 | ) 12 | 13 | // Route enforces the structure of a route 14 | type Route struct { 15 | Name string 16 | Method string 17 | Pattern string 18 | Handler http.Handler 19 | } 20 | 21 | // Routes is a collection of route structs 22 | type Routes []Route 23 | 24 | // NewRouter is used to create a new router. 25 | func NewRouter(scheduler eremetic.Scheduler, conf *config.Config, db eremetic.TaskDB) *mux.Router { 26 | h := NewHandler(scheduler, db) 27 | router := mux.NewRouter().StrictSlash(true) 28 | 29 | for _, route := range routes(h, conf) { 30 | router. 31 | Methods(route.Method). 32 | Path(route.Pattern). 33 | Name(route.Name). 34 | Handler(prometheus.InstrumentHandler(route.Name, route.Handler)) 35 | } 36 | 37 | router. 38 | PathPrefix("/static/"). 39 | Handler(h.StaticAssets()) 40 | 41 | router.NotFoundHandler = http.HandlerFunc(h.NotFound(conf)) 42 | 43 | username, password := parseHTTPCredentials(conf.HTTPCredentials) 44 | if username != "" && password != "" { 45 | router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { 46 | name := route.GetName() 47 | // `/version` can be used as health check, so ignore auth required for it 48 | if name != "Version" { 49 | route.Handler(authWrap(route.GetHandler(), username, password)) 50 | } 51 | return nil 52 | }) 53 | } 54 | 55 | return router 56 | } 57 | 58 | func routes(h Handler, conf *config.Config) Routes { 59 | v0routes := apiV0Routes(h, conf) 60 | v1routes := apiV1Routes(h, conf) 61 | apiRoutes := append(v0routes, v1routes...) 62 | return append(Routes{ 63 | Route{ 64 | Name: "Index", 65 | Method: "GET", 66 | Pattern: "/", 67 | Handler: h.IndexHandler(conf), 68 | }, 69 | Route{ 70 | Name: "Metrics", 71 | Method: "GET", 72 | Pattern: "/metrics", 73 | Handler: prometheus.Handler(), 74 | }, 75 | }, apiRoutes...) 76 | } 77 | -------------------------------------------------------------------------------- /metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | ) 8 | 9 | var ( 10 | // TasksCreated increments with each created task 11 | TasksCreated = prometheus.NewCounter(prometheus.CounterOpts{ 12 | Subsystem: "scheduler", 13 | Name: "tasks_created", 14 | Help: "Number of tasks submitted to eremetic", 15 | }) 16 | // TasksLaunched increments with each launched task 17 | TasksLaunched = prometheus.NewCounter(prometheus.CounterOpts{ 18 | Subsystem: "scheduler", 19 | Name: "tasks_launched", 20 | Help: "Number of tasks launched by eremetic", 21 | }) 22 | // TasksTerminated increments with each terminated task 23 | TasksTerminated = prometheus.NewCounterVec(prometheus.CounterOpts{ 24 | Subsystem: "scheduler", 25 | Name: "tasks_terminated", 26 | Help: "Number of terminated tasks by terminal status", 27 | }, []string{"status", "sequence"}) 28 | // TasksDelayed increments with each delayed task 29 | TasksDelayed = prometheus.NewCounter(prometheus.CounterOpts{ 30 | Subsystem: "scheduler", 31 | Name: "tasks_delayed", 32 | Help: "Number of times the launch of a task has been delayed", 33 | }) 34 | // TasksRunning provides the number of currently running tasks 35 | TasksRunning = prometheus.NewGauge(prometheus.GaugeOpts{ 36 | Subsystem: "scheduler", 37 | Name: "tasks_running", 38 | Help: "Number of tasks currently running", 39 | }) 40 | // QueueSize provides the number of tasks waiting to be launched 41 | QueueSize = prometheus.NewGauge(prometheus.GaugeOpts{ 42 | Subsystem: "scheduler", 43 | Name: "queue_size", 44 | Help: "Number of tasks in the queue", 45 | }) 46 | ) 47 | 48 | // RegisterMetrics registers mesos metrics to a prometheus Registerer. 49 | func RegisterMetrics(r prometheus.Registerer) error { 50 | errs := []error{ 51 | r.Register(TasksCreated), 52 | r.Register(TasksLaunched), 53 | r.Register(TasksTerminated), 54 | r.Register(TasksDelayed), 55 | r.Register(TasksRunning), 56 | r.Register(QueueSize), 57 | } 58 | if len(errs) > 0 { 59 | return errors.New("unable to register metrics") 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /mesos/reconcile_test.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/mesos/mesos-go/api/v0/mesosproto" 8 | . "github.com/smartystreets/goconvey/convey" 9 | 10 | "github.com/eremetic-framework/eremetic" 11 | "github.com/eremetic-framework/eremetic/mock" 12 | ) 13 | 14 | func TestReconcile(t *testing.T) { 15 | db := eremetic.NewDefaultTaskDB() 16 | 17 | maxReconciliationDelay = 1 18 | 19 | Convey("ReconcileTasks", t, func() { 20 | Convey("Finishes when there are no tasks", func() { 21 | driver := mock.NewMesosScheduler() 22 | r := reconcileTasks(driver, db) 23 | 24 | select { 25 | case <-r.done: 26 | } 27 | 28 | So(driver.ReconcileTasksFnInvoked, ShouldBeFalse) 29 | }) 30 | 31 | Convey("Sends reconcile request", func() { 32 | driver := mock.NewMesosScheduler() 33 | driver.ReconcileTasksFn = func(ts []*mesosproto.TaskStatus) (mesosproto.Status, error) { 34 | t, err := db.ReadTask("1234") 35 | if err != nil { 36 | return mesosproto.Status_DRIVER_RUNNING, err 37 | } 38 | t.UpdateStatus(eremetic.Status{ 39 | Status: eremetic.TaskRunning, 40 | Time: time.Now().Unix() + 1, 41 | }) 42 | db.PutTask(&t) 43 | 44 | return mesosproto.Status_DRIVER_RUNNING, nil 45 | } 46 | 47 | db.PutTask(&eremetic.Task{ 48 | ID: "1234", 49 | Status: []eremetic.Status{ 50 | eremetic.Status{ 51 | Status: eremetic.TaskStaging, 52 | Time: time.Now().Unix(), 53 | }, 54 | }, 55 | }) 56 | 57 | r := reconcileTasks(driver, db) 58 | 59 | select { 60 | case <-r.done: 61 | } 62 | 63 | So(driver.ReconcileTasksFnInvoked, ShouldBeTrue) 64 | }) 65 | 66 | Convey("Cancel reconciliation", func() { 67 | driver := mock.NewMesosScheduler() 68 | 69 | db.PutTask(&eremetic.Task{ 70 | ID: "1234", 71 | Status: []eremetic.Status{ 72 | eremetic.Status{ 73 | Status: eremetic.TaskStaging, 74 | Time: time.Now().Unix(), 75 | }, 76 | }, 77 | }) 78 | 79 | r := reconcileTasks(driver, db) 80 | r.Cancel() 81 | 82 | select { 83 | case <-r.done: 84 | } 85 | 86 | So(driver.ReconcileTasksFnInvoked, ShouldBeFalse) 87 | }) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /mesos/extractor_test.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func mockStatusWithSandbox() []byte { 10 | return []byte(`[ 11 | { 12 | "Mounts": [ 13 | { 14 | "Source": "/tmp/mesos/slaves//frameworks//executors//runs/", 15 | "Destination": "/mnt/mesos/sandbox", 16 | "Mode": "", 17 | "RW": true 18 | } 19 | ] 20 | } 21 | ]`) 22 | } 23 | 24 | func mockStatusWithoutSandbox() []byte { 25 | return []byte(`[ 26 | { 27 | "Mounts": [ 28 | { 29 | "Source": "/tmp/mesos/", 30 | "Destination": "/mnt/not/the/sandbox", 31 | "Mode": "", 32 | "RW": true 33 | } 34 | ] 35 | } 36 | ]`) 37 | } 38 | 39 | func mockStatusNoMounts() []byte { 40 | return []byte(`[ 41 | { 42 | "Mounts": [] 43 | } 44 | ]`) 45 | } 46 | 47 | func TestExtractor(t *testing.T) { 48 | Convey("extractSandboxPath", t, func() { 49 | Convey("Sandbox found", func() { 50 | status := mockStatusWithSandbox() 51 | sandbox, err := extractSandboxPath(status) 52 | So(err, ShouldBeNil) 53 | So(sandbox, ShouldNotBeEmpty) 54 | }) 55 | 56 | Convey("Sandbox not found", func() { 57 | status := mockStatusWithoutSandbox() 58 | sandbox, err := extractSandboxPath(status) 59 | So(sandbox, ShouldBeEmpty) 60 | So(err, ShouldBeNil) 61 | }) 62 | 63 | Convey("No mounts in data", func() { 64 | status := mockStatusWithoutSandbox() 65 | sandbox, err := extractSandboxPath(status) 66 | So(sandbox, ShouldBeEmpty) 67 | So(err, ShouldBeNil) 68 | }) 69 | 70 | Convey("Empty data", func() { 71 | sandbox, err := extractSandboxPath([]byte("")) 72 | So(sandbox, ShouldBeEmpty) 73 | So(err, ShouldBeNil) 74 | }) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/eremetic-framework/eremetic 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a 7 | github.com/boltdb/bolt v1.3.1 8 | github.com/braintree/manners v0.0.0-20150503212558-0b5e6b2c2843 9 | github.com/davecgh/go-spew v1.1.1 10 | github.com/elazarl/go-bindata-assetfs v1.0.0 11 | github.com/go-bindata/go-bindata v3.1.2+incompatible // indirect 12 | github.com/gogo/protobuf v0.0.0-20170307180453-100ba4e88506 13 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b 14 | github.com/golang/protobuf v0.0.0-20171021043952-1643683e1b54 15 | github.com/gorilla/context v1.1.1 16 | github.com/gorilla/mux v1.4.0 17 | github.com/gorilla/schema v0.0.0-20171101174852-e6c82218a8b3 18 | github.com/jacobsa/oglematchers v0.0.0-20150720000706-141901ea67cd 19 | github.com/jacobsa/oglemock v0.0.0-20150831005832-e94d794d06ff // indirect 20 | github.com/jacobsa/ogletest v0.0.0-20170503003838-80d50a735a11 // indirect 21 | github.com/jacobsa/reqtrace v0.0.0-20150505043853-245c9e0234cb // indirect 22 | github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 23 | github.com/kelseyhightower/envconfig v1.3.0 24 | github.com/kevinburke/go-bindata v3.13.0+incompatible // indirect 25 | github.com/matttproud/golang_protobuf_extensions v1.0.1 26 | github.com/mesos/mesos-go v0.0.0-20170604182343-f2cd423e881b 27 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 28 | github.com/pborman/uuid v0.0.0-20160209185913-a97ce2ca70fa 29 | github.com/pmezard/go-difflib v1.0.0 30 | github.com/prometheus/client_golang v0.8.0 31 | github.com/prometheus/client_model v0.0.0-20170216185247-6f3806018612 32 | github.com/prometheus/common v0.0.0-20171104095907-e3fb1a1acd76 33 | github.com/prometheus/procfs v0.0.0-20171017214025-a6e9df898b13 34 | github.com/samuel/go-zookeeper v0.0.0-20171027001500-9a96098268ef 35 | github.com/sirupsen/logrus v1.4.2 36 | github.com/smartystreets/goconvey v1.6.4 37 | github.com/stretchr/objx v0.1.1 38 | github.com/stretchr/testify v1.2.2 39 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 40 | golang.org/x/net v0.0.0-20190311183353-d8887717615a 41 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 // indirect 42 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894 43 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect 44 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7 45 | ) 46 | -------------------------------------------------------------------------------- /server/static/images/cpu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 11 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /server/static/js/task_view.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | "use strict"; 3 | 4 | var taskId = $('body').data('task'); 5 | 6 | function format(data) { 7 | data = data.split("\n"); 8 | 9 | var $el = $('#stdout'), 10 | cls = 'gray', 11 | showNext = false; 12 | $.each(data, function(i, v) { 13 | if (showNext && cls == 'gray') { 14 | cls = ''; 15 | } 16 | if (showNext) { 17 | $el.append($('

').append(ansi_up.ansi_to_html(v))); 18 | } else { 19 | $el.append($('

', { text: v, class: cls })); 20 | } 21 | 22 | if (v.indexOf("Starting task") == 0) { 23 | showNext = true; 24 | } 25 | }) 26 | $el.find('.gray').hide(); 27 | $('#show_stdout').text($el.find('.gray').length + ' lines hidden. Click to show.'); 28 | } 29 | 30 | function getLogs(logfile) { 31 | var $el = $("#" + logfile); 32 | if (!$el) { 33 | return; 34 | } 35 | $.ajax({ 36 | method: 'GET', 37 | url: EREMETIC_URL_PREFIX + '/api/v1/task/' + taskId + '/' + logfile, 38 | success: function(data) { 39 | if (typeof data === 'undefined') { 40 | return 41 | } 42 | if (data.length == 0) { 43 | $('div.logs').hide(); 44 | return; 45 | } 46 | $el.text(''); 47 | if (logfile === 'stdout') { 48 | format(data); 49 | } else { 50 | $.each(data.split("\n"), function(i, v) { 51 | $el.append($('

', { text: v, class: 'gray' })); 52 | }); 53 | }; 54 | }, 55 | error: function(xhr, e) { 56 | $el.text(e) 57 | } 58 | }); 59 | } 60 | 61 | $('body').on('click', '#kill', function(e) { 62 | e.preventDefault(); 63 | $.ajax({ 64 | method: 'POST', 65 | url: EREMETIC_URL_PREFIX + '/api/v1/task/' + taskId + '/kill', 66 | success: function() { 67 | window.location = window.location; 68 | }, 69 | error: function(xhr, e) { 70 | $('.error.hidden').removeClass('hidden') 71 | $('.error .information').text(xhr.responseText) 72 | } 73 | }); 74 | }) 75 | 76 | $('body').on('click', '.close', function(e) { 77 | e.preventDefault(); 78 | $(this).parents('.ui.error').addClass('hidden') 79 | }) 80 | 81 | $('body').on('click', '#show_stdout', function(e) { 82 | e.preventDefault(); 83 | $('#stdout p.gray').show(); 84 | $('#show_stdout').remove(); 85 | }) 86 | 87 | getLogs('stdout'); 88 | getLogs('stderr'); 89 | }) 90 | -------------------------------------------------------------------------------- /callback_test.go: -------------------------------------------------------------------------------- 1 | package eremetic 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | type callbackHandler struct { 14 | Invoked bool 15 | Payload map[string]interface{} 16 | } 17 | 18 | func (h *callbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 | if err := json.NewDecoder(r.Body).Decode(&h.Payload); err != nil { 20 | w.WriteHeader(http.StatusInternalServerError) 21 | return 22 | } 23 | 24 | h.Invoked = true 25 | } 26 | 27 | func TestCallback(t *testing.T) { 28 | Convey("Given an empty task", t, func() { 29 | var h callbackHandler 30 | ts := httptest.NewServer(&h) 31 | 32 | var task Task 33 | 34 | Convey("When notifying without a callback URI", func() { 35 | NotifyCallback(&task) 36 | time.Sleep(50 * time.Millisecond) 37 | 38 | Convey("The callback handler should not be invoked", func() { 39 | So(h.Invoked, ShouldBeFalse) 40 | }) 41 | }) 42 | Convey("When notifying without any statuses", func() { 43 | task.CallbackURI = ts.URL 44 | 45 | NotifyCallback(&task) 46 | time.Sleep(10 * time.Millisecond) 47 | 48 | Convey("The callback handler should be invoked", func() { 49 | So(h.Invoked, ShouldBeFalse) 50 | }) 51 | }) 52 | Convey("When notifying with one status", func() { 53 | task.CallbackURI = ts.URL 54 | task.Status = []Status{ 55 | {Time: 0, Status: TaskStaging}, 56 | } 57 | 58 | NotifyCallback(&task) 59 | time.Sleep(10 * time.Millisecond) 60 | 61 | Convey("The callback handler should be invoked", func() { 62 | So(h.Invoked, ShouldBeTrue) 63 | }) 64 | Convey("The callback payload status should contain the status", func() { 65 | So(h.Payload, ShouldContainKey, "status") 66 | So(h.Payload["status"], ShouldEqual, "TASK_STAGING") 67 | }) 68 | }) 69 | Convey("When notifying with many statuses", func() { 70 | task.CallbackURI = ts.URL 71 | task.Status = []Status{ 72 | {Time: 0, Status: TaskStaging}, 73 | {Time: 1, Status: TaskRunning}, 74 | {Time: 2, Status: TaskFinished}, 75 | } 76 | 77 | NotifyCallback(&task) 78 | time.Sleep(10 * time.Millisecond) 79 | 80 | Convey("The callback handler should be invoked", func() { 81 | So(h.Invoked, ShouldBeTrue) 82 | }) 83 | Convey("The callback payload status should contain the latest status", func() { 84 | So(h.Payload, ShouldContainKey, "status") 85 | So(h.Payload["status"], ShouldEqual, "TASK_FINISHED") 86 | }) 87 | }) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /mesos/reconcile.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/golang/protobuf/proto" 8 | "github.com/mesos/mesos-go/api/v0/mesosproto" 9 | mesossched "github.com/mesos/mesos-go/api/v0/scheduler" 10 | 11 | "github.com/eremetic-framework/eremetic" 12 | ) 13 | 14 | var ( 15 | maxReconciliationDelay = 120 16 | ) 17 | 18 | type reconciler struct { 19 | cancel chan struct{} 20 | done chan struct{} 21 | } 22 | 23 | func (r *reconciler) Cancel() { 24 | close(r.cancel) 25 | } 26 | 27 | func reconcileTasks(driver mesossched.SchedulerDriver, database eremetic.TaskDB) *reconciler { 28 | cancel := make(chan struct{}) 29 | done := make(chan struct{}) 30 | 31 | go func() { 32 | var ( 33 | c uint 34 | delay = 1 35 | ) 36 | 37 | tasks, err := database.ListTasks(&eremetic.TaskFilter{ 38 | State: eremetic.DefaultTaskFilterState, 39 | }) 40 | if err != nil { 41 | logrus.WithError(err).Error("Failed to list non-terminal tasks") 42 | close(done) 43 | return 44 | } 45 | 46 | logrus.Infof("Trying to reconcile with %d task(s)", len(tasks)) 47 | start := time.Now() 48 | 49 | for len(tasks) > 0 { 50 | select { 51 | case <-cancel: 52 | logrus.Info("Cancelling reconciliation job") 53 | close(done) 54 | return 55 | case <-time.After(time.Duration(delay) * time.Second): 56 | // Filter tasks that has received a status update 57 | ntasks := []*eremetic.Task{} 58 | for _, t := range tasks { 59 | nt, err := database.ReadTask(t.ID) 60 | if err != nil { 61 | logrus.WithField("task_id", t.ID).Warn("Task not found in database") 62 | continue 63 | } 64 | if nt.LastUpdated().Before(start) { 65 | ntasks = append(ntasks, &nt) 66 | } 67 | } 68 | tasks = ntasks 69 | 70 | // Send reconciliation request 71 | if len(tasks) > 0 { 72 | var statuses []*mesosproto.TaskStatus 73 | for _, t := range tasks { 74 | statuses = append(statuses, &mesosproto.TaskStatus{ 75 | State: mesosproto.TaskState_TASK_STAGING.Enum(), 76 | TaskId: &mesosproto.TaskID{Value: proto.String(t.ID)}, 77 | SlaveId: &mesosproto.SlaveID{Value: proto.String(t.AgentID)}, 78 | }) 79 | } 80 | logrus.WithField("reconciliation_request_count", c).Debug("Sending reconciliation request") 81 | driver.ReconcileTasks(statuses) 82 | } 83 | 84 | if delay < maxReconciliationDelay { 85 | delay = 10 << c 86 | if delay >= maxReconciliationDelay { 87 | delay = maxReconciliationDelay 88 | } 89 | } 90 | 91 | c++ 92 | } 93 | } 94 | 95 | logrus.Info("Reconciliation done") 96 | close(done) 97 | }() 98 | 99 | return &reconciler{ 100 | cancel: cancel, 101 | done: done, 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all test test-server test-docker docker docker-clean publish-docker 2 | 3 | REPO=github.com/eremetic-framework/eremetic 4 | VERSION?=$(shell git describe HEAD | sed s/^v//) 5 | DATE?=$(shell date -u '+%Y-%m-%d_%H:%M:%S') 6 | DOCKERNAME?=alde/eremetic 7 | DOCKERTAG?=${DOCKERNAME}:${VERSION} 8 | LDFLAGS=-X ${REPO}/version.Version=${VERSION} -X ${REPO}/version.BuildDate=${DATE} 9 | TOOLS=${GOPATH}/bin/go-bindata \ 10 | ${GOPATH}/bin/go-bindata-assetfs \ 11 | ${GOPATH}/bin/goconvey 12 | SRC=$(shell find . -name '*.go') 13 | STATIC=$(shell find server/static server/templates) 14 | TESTFLAGS="-v" 15 | 16 | DOCKER_GO_SRC_PATH=/go/src/github.com/eremetic-framework/eremetic 17 | DOCKER_GOLANG_RUN_CMD=docker run --rm -v "$(PWD)":/opt/eremetic -w /opt/eremetic golang:1.12 bash -c 18 | 19 | PACKAGES=$(shell go list ./... | grep -v /vendor/) 20 | 21 | all: test 22 | 23 | deps: ${TOOLS} 24 | 25 | ${TOOLS}: 26 | curl https://bin.equinox.io/a/75VeNN6mcnk/github-com-kevinburke-go-bindata-go-bindata-linux-amd64.tar.gz | tar xfz - -C $GOPATH/bin/ 27 | go get -u github.com/elazarl/go-bindata-assetfs/... 28 | go get -u github.com/smartystreets/goconvey 29 | 30 | test: eremetic 31 | go test ${TESTFLAGS} ${PACKAGES} 32 | 33 | test-server: ${TOOLS} 34 | ${GOPATH}/bin/goconvey 35 | 36 | # Run tests cleanly in a docker container. 37 | test-docker: 38 | $(DOCKER_GOLANG_RUN_CMD) "make test" 39 | 40 | vet: 41 | go vet ${PACKAGES} 42 | 43 | lint: 44 | golint -set_exit_status $(shell go list ./... | grep -v /vendor/ | grep -v assets) 45 | 46 | server/assets/assets.go: server/generate.go ${STATIC} 47 | go generate github.com/eremetic-framework/eremetic/server 48 | 49 | eremetic: ${TOOLS} server/assets/assets.go 50 | eremetic: ${SRC} 51 | go build -ldflags "${LDFLAGS}" -o $@ github.com/eremetic-framework/eremetic/cmd/eremetic 52 | 53 | docker/eremetic: ${TOOLS} server/assets/assets.go 54 | docker/eremetic: ${SRC} 55 | CGO_ENABLED=0 GOOS=linux go build -ldflags "${LDFLAGS}" -a -installsuffix cgo -o $@ github.com/eremetic-framework/eremetic/cmd/eremetic 56 | 57 | docker: docker/eremetic docker/Dockerfile docker/marathon.sh 58 | docker build -t ${DOCKERTAG} docker 59 | 60 | docker-clean: docker/Dockerfile docker/marathon.sh 61 | # Create the docker/eremetic binary in the Docker container using the 62 | # golang docker image. This ensures a completely clean build. 63 | $(DOCKER_GOLANG_RUN_CMD) "make docker/eremetic" 64 | docker build -t ${DOCKERTAG} docker 65 | 66 | publish-docker: 67 | #ifeq ($(strip $(shell docker images --format="{{.Repository}}:{{.Tag}}" $(DOCKERTAG))),) 68 | # $(warning Docker tag does not exist:) 69 | # $(warning ${DOCKERTAG}) 70 | # $(warning ) 71 | # $(error Cannot publish the docker image. Please run `make docker` or `make docker-clean` first.) 72 | #endif 73 | docker push ${DOCKERTAG} 74 | git describe HEAD --exact 2>/dev/null && \ 75 | docker tag ${DOCKERTAG} ${DOCKERNAME}:latest && \ 76 | docker push ${DOCKERNAME}:latest || true 77 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Running a simple health check task 4 | 5 | This runs the `dkeis/dht-probe` image with the default command, it will check 6 | if we can find peers on the network with dht. 7 | 8 | ```json 9 | { 10 | "docker_image": "dkeis/dht-probe", 11 | "task_cpus": 0.1, 12 | "task_mem": 100.0 13 | } 14 | ``` 15 | 16 | ## Interfacing with docker on the host 17 | 18 | By mounting the docker socket into the container the application is able to 19 | interface with the docker api. Here we use 20 | [cibox](https://github.com/keis/cibox) to run tests in a dynamically created 21 | docker container. 22 | 23 | Beware security implications of mounting the docker socket as it gives full 24 | access to the *host* system. 25 | 26 | ```json 27 | { 28 | "command": "/cibox https://git@github.com/keis/cibox.git --matrix-id 0", 29 | "docker_image": "dkeis/cibox", 30 | "task_cpus": 0.1, 31 | "task_mem": 100.0, 32 | "volumes": [ 33 | { 34 | "container_path": "/var/run/docker.sock", 35 | "host_path": "/var/run/docker.sock" 36 | } 37 | ] 38 | } 39 | ``` 40 | 41 | ## Building a docker image 42 | 43 | The docker api also enables us to build docker images and by combining this 44 | with a list of `uris` that will be downloaded by mesos we can build images from 45 | arbitrary dockerfiles by url. 46 | 47 | ```json 48 | { 49 | "command": "docker build -t dkeis/golang /tmp/build && docker login -u $USER -p $PASSWORD && docker push dkeis/golang", 50 | "docker_image": "docker:1.8", 51 | "env": { 52 | "USER": "dkeis" 53 | }, 54 | "masked_env": { 55 | "PASSWORD": "myactualpassword" 56 | }, 57 | "task_cpus": 0.1, 58 | "task_mem": 100.0, 59 | "uris": [ 60 | "https://raw.githubusercontent.com/docker-library/golang/3cdd85183c0f3f6608588166410d24260cd8cb2f/1.6/alpine/Dockerfile" 61 | ], 62 | "volumes": [ 63 | { 64 | "container_path": "/var/run/docker.sock", 65 | "host_path": "/var/run/docker.sock" 66 | }, 67 | { 68 | "container_path": "/tmp/build/Dockerfile", 69 | "host_path": "Dockerfile" 70 | } 71 | ] 72 | } 73 | ``` 74 | 75 | ## Running a task with certain attributes 76 | 77 | This configures a task to run the `busybox` image with a basic loop outputting 78 | the time on any Mesos Slave with the attribute "role" set to "build". 79 | 80 | ```json 81 | { 82 | "docker_image": "busybox", 83 | "command": "for i in $(seq 1 5); do echo \"`date` $i\"; sleep 5; done", 84 | "task_cpus": 0.1, 85 | "task_mem": 100.0, 86 | "slave_constraints": [ 87 | { 88 | "attribute_name": "role", 89 | "attribute_value": "build" 90 | } 91 | ] 92 | } 93 | ``` 94 | 95 | Mesos slaves can be configured with arbitrary attributes. See the 96 | [documentation](https://open.mesosphere.com/reference/mesos-slave/) for more 97 | information on how to configure attributes. 98 | -------------------------------------------------------------------------------- /mesos/driver.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "net" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/golang/protobuf/proto" 11 | "github.com/mesos/mesos-go/api/v0/auth" 12 | "github.com/mesos/mesos-go/api/v0/mesosproto" 13 | mesossched "github.com/mesos/mesos-go/api/v0/scheduler" 14 | "golang.org/x/net/context" 15 | ) 16 | 17 | func getFrameworkID(scheduler *Scheduler) *mesosproto.FrameworkID { 18 | if scheduler.frameworkID != "" { 19 | return &mesosproto.FrameworkID{ 20 | Value: proto.String(scheduler.frameworkID), 21 | } 22 | } 23 | return nil 24 | } 25 | 26 | func getPrincipalID(credential *mesosproto.Credential) *string { 27 | if credential != nil { 28 | return credential.Principal 29 | } 30 | return nil 31 | } 32 | 33 | func getCredential(settings *Settings) (*mesosproto.Credential, error) { 34 | if settings.CredentialFile != "" { 35 | content, err := ioutil.ReadFile(settings.CredentialFile) 36 | if err != nil { 37 | logrus.WithError(err).WithFields(logrus.Fields{ 38 | "credential_file": settings.CredentialFile, 39 | }).Error("Unable to read credential_file") 40 | return nil, err 41 | } 42 | fields := strings.Fields(string(content)) 43 | 44 | if len(fields) != 2 { 45 | err := errors.New("Unable to parse credentials") 46 | logrus.WithError(err).WithFields(logrus.Fields{ 47 | "credential_file": settings.CredentialFile, 48 | }).Error("Should only contain a key and a secret separated by whitespace") 49 | return nil, err 50 | } 51 | 52 | logrus.WithField("principal", fields[0]).Info("Successfully loaded principal") 53 | return &mesosproto.Credential{ 54 | Principal: proto.String(fields[0]), 55 | Secret: proto.String(fields[1]), 56 | }, nil 57 | } 58 | logrus.Debug("No credentials specified in configuration") 59 | return nil, nil 60 | } 61 | 62 | func getAuthContext(ctx context.Context) context.Context { 63 | return auth.WithLoginProvider(ctx, "SASL") 64 | } 65 | 66 | func createDriver(scheduler *Scheduler, settings *Settings) (*mesossched.MesosSchedulerDriver, error) { 67 | publishedAddr := net.ParseIP(settings.MessengerAddress) 68 | bindingPort := settings.MessengerPort 69 | credential, err := getCredential(settings) 70 | 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return mesossched.NewMesosSchedulerDriver(mesossched.DriverConfig{ 76 | Master: settings.Master, 77 | Framework: &mesosproto.FrameworkInfo{ 78 | Id: getFrameworkID(scheduler), 79 | Name: proto.String(settings.Name), 80 | User: proto.String(settings.User), 81 | Checkpoint: proto.Bool(settings.Checkpoint), 82 | FailoverTimeout: proto.Float64(settings.FailoverTimeout), 83 | Principal: getPrincipalID(credential), 84 | }, 85 | Scheduler: scheduler, 86 | BindingAddress: net.ParseIP("0.0.0.0"), 87 | PublishedAddress: publishedAddr, 88 | BindingPort: bindingPort, 89 | Credential: credential, 90 | WithAuthContext: getAuthContext, 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/kardianos/osext" 9 | "github.com/kelseyhightower/envconfig" 10 | yaml "gopkg.in/yaml.v2" 11 | ) 12 | 13 | // The Config struct holds the Eremetic Configuration 14 | type Config struct { 15 | // Logging 16 | LogLevel string `yaml:"loglevel"` 17 | LogFormat string `yaml:"logformat"` 18 | 19 | // Server 20 | Address string `yaml:"address"` 21 | Port int `yaml:"port"` 22 | HTTPCredentials string `yaml:"http_credentials" envconfig:"http_credentials"` 23 | URLPrefix string `yaml:"url_prefix" envconfig:"url_prefix"` 24 | 25 | // Database 26 | DatabaseDriver string `yaml:"database_driver" envconfig:"database_driver"` 27 | DatabasePath string `yaml:"database" envconfig:"database"` 28 | 29 | // Mesos 30 | Name string `yaml:"name"` 31 | User string `yaml:"user"` 32 | Checkpoint bool `yaml:"checkpoint"` 33 | FailoverTimeout float64 `yaml:"failover_timeout" envconfig:"failover_timeout"` 34 | QueueSize int `yaml:"queue_size" envconfig:"queue_size"` 35 | Master string `yaml:"master"` 36 | FrameworkID string `yaml:"framework_id" envconfig:"framework_id"` 37 | CredentialsFile string `yaml:"credential_file" envconfig:"credential_file"` 38 | MessengerAddress string `yaml:"messenger_address" envconfig:"messenger_address"` 39 | MessengerPort int `yaml:"messenger_port" envconfig:"messenger_port"` 40 | } 41 | 42 | // DefaultConfig returns a Config struct with the default settings 43 | func DefaultConfig() *Config { 44 | return &Config{ 45 | LogLevel: "debug", 46 | LogFormat: "text", 47 | 48 | DatabaseDriver: "boltdb", 49 | DatabasePath: "db/eremetic.db", 50 | 51 | Name: "Eremetic", 52 | User: "root", 53 | Checkpoint: true, 54 | FailoverTimeout: 2592000.0, 55 | QueueSize: 100, 56 | FrameworkID: "1234", 57 | } 58 | } 59 | 60 | // GetConfigFilePath returns the location of the config file in order of priority: 61 | // 1 ) File in same directory as the executable 62 | // 2 ) Global file in /etc/eremetic/eremetic.yml 63 | func GetConfigFilePath() string { 64 | path, _ := osext.ExecutableFolder() 65 | path = fmt.Sprintf("%s/eremetic.yml", path) 66 | if _, err := os.Open(path); err == nil { 67 | return path 68 | } 69 | globalPath := "/etc/eremetic/eremetic.yml" 70 | if _, err := os.Open(globalPath); err == nil { 71 | return globalPath 72 | } 73 | 74 | return "" 75 | } 76 | 77 | // ReadConfigFile reads the config file and overrides any values net in both it 78 | // and the DefaultConfig 79 | func ReadConfigFile(conf *Config, path string) { 80 | file, err := os.Open(path) 81 | if err != nil { 82 | return 83 | } 84 | 85 | configFile, _ := ioutil.ReadAll(file) 86 | yaml.Unmarshal(configFile, conf) 87 | 88 | if conf.DatabaseDriver == "boltdb" && conf.DatabasePath == "" { 89 | conf.DatabasePath = "db/eremetic.db" 90 | } 91 | } 92 | 93 | // ReadEnvironment takes environment variables and overrides any values from 94 | // DefaultConfig and the Config file. 95 | func ReadEnvironment(conf *Config) { 96 | envconfig.Process("", conf) 97 | } 98 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package eremetic 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "sync" 7 | ) 8 | 9 | // Masking is the string used for masking environment variables. 10 | const Masking = "*******" 11 | 12 | // ApplyMask replaces masked environment variables with a masking string. 13 | func ApplyMask(task *Task) { 14 | for k := range task.MaskedEnvironment { 15 | task.MaskedEnvironment[k] = Masking 16 | } 17 | } 18 | 19 | // Encode encodes a task into a JSON byte array. 20 | func Encode(task *Task) ([]byte, error) { 21 | encoded, err := json.Marshal(task) 22 | return []byte(encoded), err 23 | } 24 | 25 | // TaskDB defines the functions needed by the database abstraction layer 26 | type TaskDB interface { 27 | Clean() error 28 | Close() 29 | PutTask(task *Task) error 30 | ReadTask(id string) (Task, error) 31 | DeleteTask(id string) error 32 | ReadUnmaskedTask(id string) (Task, error) 33 | ListTasks(filter *TaskFilter) ([]*Task, error) 34 | } 35 | 36 | // DefaultTaskDB is a in-memory implementation of TaskDB. 37 | type DefaultTaskDB struct { 38 | mtx sync.RWMutex 39 | tasks map[string]*Task 40 | } 41 | 42 | // NewDefaultTaskDB returns a new instance of TaskDB. 43 | func NewDefaultTaskDB() *DefaultTaskDB { 44 | return &DefaultTaskDB{ 45 | tasks: make(map[string]*Task), 46 | } 47 | } 48 | 49 | // Clean removes all tasks from the database. 50 | func (db *DefaultTaskDB) Clean() error { 51 | db.tasks = make(map[string]*Task) 52 | return nil 53 | } 54 | 55 | // Close closes the connection to the database. 56 | func (db *DefaultTaskDB) Close() { 57 | return 58 | } 59 | 60 | // PutTask adds a new task to the database. 61 | func (db *DefaultTaskDB) PutTask(task *Task) error { 62 | db.mtx.Lock() 63 | defer db.mtx.Unlock() 64 | db.tasks[task.ID] = task 65 | return nil 66 | } 67 | 68 | // ReadTask returns a task with a given id, or an error if not found. 69 | func (db *DefaultTaskDB) ReadTask(id string) (Task, error) { 70 | db.mtx.RLock() 71 | defer db.mtx.RUnlock() 72 | if task, ok := db.tasks[id]; ok { 73 | ApplyMask(task) 74 | return *task, nil 75 | } 76 | return Task{}, errors.New("unknown task") 77 | } 78 | 79 | // DeleteTask removes the task with a given id, or an error if not found. 80 | func (db *DefaultTaskDB) DeleteTask(id string) error { 81 | db.mtx.RLock() 82 | defer db.mtx.RUnlock() 83 | if _, ok := db.tasks[id]; ok { 84 | delete(db.tasks, id) 85 | return nil 86 | } 87 | return errors.New("unknown task") 88 | } 89 | 90 | // ReadUnmaskedTask returns a task with all its environment variables unmasked. 91 | func (db *DefaultTaskDB) ReadUnmaskedTask(id string) (Task, error) { 92 | db.mtx.RLock() 93 | defer db.mtx.RUnlock() 94 | if task, ok := db.tasks[id]; ok { 95 | return *task, nil 96 | } 97 | return Task{}, errors.New("unknown task") 98 | } 99 | 100 | // ListTasks returns all tasks based on the filter. 101 | func (db *DefaultTaskDB) ListTasks(filter *TaskFilter) ([]*Task, error) { 102 | db.mtx.RLock() 103 | defer db.mtx.RUnlock() 104 | res := []*Task{} 105 | for _, t := range db.tasks { 106 | if filter.Match(t) { 107 | res = append(res, t) 108 | } 109 | } 110 | return res, nil 111 | } 112 | -------------------------------------------------------------------------------- /server/helpers_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | . "github.com/smartystreets/goconvey/convey" 13 | 14 | "github.com/eremetic-framework/eremetic" 15 | "github.com/eremetic-framework/eremetic/config" 16 | ) 17 | 18 | func TestHandlingHelpers(t *testing.T) { 19 | 20 | status := []eremetic.Status{ 21 | eremetic.Status{ 22 | Status: eremetic.TaskRunning, 23 | Time: time.Now().Unix(), 24 | }, 25 | } 26 | 27 | Convey("writeJSON", t, func() { 28 | Convey("Should respond with a JSON and the appropriate status code", func() { 29 | var wr = httptest.NewRecorder() 30 | 31 | writeJSON(200, "foo", wr) 32 | contentType := wr.HeaderMap["Content-Type"][0] 33 | So(contentType, ShouldEqual, "application/json; charset=UTF-8") 34 | So(wr.Code, ShouldEqual, http.StatusOK) 35 | }) 36 | }) 37 | 38 | Convey("HandleError", t, func() { 39 | wr := httptest.NewRecorder() 40 | 41 | Convey("It should return an error status code", func() { 42 | err := errors.New("Error") 43 | 44 | handleError(err, wr, "A test error") 45 | 46 | So(wr.Code, ShouldEqual, 422) 47 | So(strings.TrimSpace(wr.Body.String()), ShouldEqual, "{\"error\":\"Error\",\"message\":\"A test error\"}") 48 | }) 49 | }) 50 | 51 | Convey("renderHTML", t, func() { 52 | id := "eremetic-task.1234" 53 | 54 | task := eremetic.Task{ 55 | TaskCPUs: 0.2, 56 | TaskMem: 0.5, 57 | Command: "test", 58 | Image: "test", 59 | Status: status, 60 | ID: id, 61 | } 62 | 63 | wr := httptest.NewRecorder() 64 | r, _ := http.NewRequest("GET", "/task/eremetic-task.1234", nil) 65 | 66 | renderHTML(wr, r, task, id, &config.Config{}) 67 | 68 | body, _ := ioutil.ReadAll(wr.Body) 69 | So(body, ShouldNotBeEmpty) 70 | So(string(body), ShouldContainSubstring, "html") 71 | }) 72 | 73 | Convey("makeMap", t, func() { 74 | task := eremetic.Task{ 75 | TaskCPUs: 0.2, 76 | TaskMem: 0.5, 77 | Command: "test", 78 | Image: "test", 79 | Status: status, 80 | ID: "eremetic-task.1234", 81 | } 82 | 83 | data := makeMap(task) 84 | So(data, ShouldContainKey, "CPU") 85 | So(data, ShouldContainKey, "Memory") 86 | So(data, ShouldContainKey, "Status") 87 | So(data, ShouldContainKey, "ContainerImage") 88 | So(data, ShouldContainKey, "Command") 89 | So(data, ShouldContainKey, "TaskID") 90 | }) 91 | 92 | Convey("notFound", t, func() { 93 | wr := httptest.NewRecorder() 94 | r, _ := http.NewRequest("GET", "/task/eremetic-task.1234", nil) 95 | Convey("text/html", func() { 96 | r.Header.Add("Accept", "text/html") 97 | 98 | notFound(wr, r, &config.Config{}) 99 | b, _ := ioutil.ReadAll(wr.Body) 100 | body := string(b) 101 | So(wr.Code, ShouldEqual, http.StatusNotFound) 102 | So(body, ShouldContainSubstring, "404 Not Found | Eremetic") 103 | }) 104 | 105 | Convey("application/json", func() { 106 | notFound(wr, r, &config.Config{}) 107 | So(wr.Code, ShouldEqual, http.StatusNotFound) 108 | So(wr.Header().Get("Content-Type"), ShouldContainSubstring, "application/json") 109 | }) 110 | }) 111 | 112 | } 113 | -------------------------------------------------------------------------------- /mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/eremetic-framework/eremetic" 7 | ) 8 | 9 | // Scheduler mocks the eremetic scheduler. 10 | type Scheduler struct { 11 | ScheduleTaskFn func(req eremetic.Request) (string, error) 12 | ScheduleTaskInvoked bool 13 | KillFn func(id string) error 14 | KillInvoked bool 15 | } 16 | 17 | // ScheduleTask invokes the ScheduleTaskFn function. 18 | func (s *Scheduler) ScheduleTask(req eremetic.Request) (string, error) { 19 | s.ScheduleTaskInvoked = true 20 | return s.ScheduleTaskFn(req) 21 | } 22 | 23 | // Kill simulates the Kill functionality 24 | func (s *Scheduler) Kill(id string) error { 25 | s.KillInvoked = true 26 | return s.KillFn(id) 27 | } 28 | 29 | // TaskDB mocks the eremetic task database. 30 | type TaskDB struct { 31 | CleanFn func() error 32 | CloseFn func() 33 | PutTaskFn func(*eremetic.Task) error 34 | ReadTaskFn func(string) (eremetic.Task, error) 35 | ReadUnmaskedTaskFn func(string) (eremetic.Task, error) 36 | DeleteTaskFn func(string) error 37 | ListNonTerminalTasksFn func() ([]*eremetic.Task, error) 38 | ListTasksFn func(*eremetic.TaskFilter) ([]*eremetic.Task, error) 39 | } 40 | 41 | // Clean invokes the CleanFn function. 42 | func (db *TaskDB) Clean() error { 43 | return db.CleanFn() 44 | } 45 | 46 | // Close invokes the CloseFn function. 47 | func (db *TaskDB) Close() { 48 | db.CloseFn() 49 | } 50 | 51 | // PutTask invokes the PutTaskFn function. 52 | func (db *TaskDB) PutTask(task *eremetic.Task) error { 53 | return db.PutTaskFn(task) 54 | } 55 | 56 | // ReadTask invokes the ReadTaskFn function. 57 | func (db *TaskDB) ReadTask(id string) (eremetic.Task, error) { 58 | return db.ReadTaskFn(id) 59 | } 60 | 61 | // ReadUnmaskedTask invokes the ReadUnmaskedTaskFn function. 62 | func (db *TaskDB) ReadUnmaskedTask(id string) (eremetic.Task, error) { 63 | return db.ReadUnmaskedTaskFn(id) 64 | } 65 | 66 | // DeleteTask invokes the DeleteTaskFn function. 67 | func (db *TaskDB) DeleteTask(id string) error { 68 | return db.DeleteTaskFn(id) 69 | } 70 | 71 | // ListNonTerminalTasks invokes the ListNonTerminalTasksFn function. 72 | func (db *TaskDB) ListNonTerminalTasks() ([]*eremetic.Task, error) { 73 | return db.ListNonTerminalTasksFn() 74 | } 75 | 76 | // ListTasks invokes the ListTasksFn function. 77 | func (db *TaskDB) ListTasks(filter *eremetic.TaskFilter) ([]*eremetic.Task, error) { 78 | return db.ListTasksFn(filter) 79 | } 80 | 81 | // ErrScheduler mocks the eremetic scheduler. 82 | type ErrScheduler struct { 83 | NextError *error 84 | } 85 | 86 | // ScheduleTask records any scheduling errors. 87 | func (s *ErrScheduler) ScheduleTask(request eremetic.Request) (string, error) { 88 | if err := s.NextError; err != nil { 89 | s.NextError = nil 90 | return "", *err 91 | 92 | } 93 | return "eremetic-task.mock", nil 94 | } 95 | 96 | // Kill simulates the Kill functionality 97 | func (s *ErrScheduler) Kill(_id string) error { 98 | return nil 99 | } 100 | 101 | // ErrorReader simulates a failure to read stream. 102 | type ErrorReader struct{} 103 | 104 | // Read always returns an error. 105 | func (r *ErrorReader) Read(p []byte) (int, error) { 106 | return 0, errors.New("oh no") 107 | 108 | } 109 | -------------------------------------------------------------------------------- /client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/eremetic-framework/eremetic/api" 10 | "github.com/eremetic-framework/eremetic/version" 11 | ) 12 | 13 | func TestClient_AddTask(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | 16 | })) 17 | defer ts.Close() 18 | 19 | var httpClient http.Client 20 | 21 | c, err := New(ts.URL, &httpClient) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | var req api.RequestV1 27 | 28 | if err := c.AddTask(req); err != nil { 29 | t.Fatal(err) 30 | } 31 | } 32 | 33 | func TestClient_Tasks(t *testing.T) { 34 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | w.Write([]byte(`[{ 36 | "id": "eremetic-id-12345" 37 | }]`)) 38 | })) 39 | defer ts.Close() 40 | 41 | var httpClient http.Client 42 | 43 | c, err := New(ts.URL, &httpClient) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | tasks, err := c.Tasks() 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | if len(tasks) != 1 { 54 | t.Fail() 55 | } 56 | } 57 | 58 | func TestClient_KillTask(t *testing.T) { 59 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 | w.WriteHeader(http.StatusAccepted) 61 | })) 62 | defer ts.Close() 63 | 64 | var httpClient http.Client 65 | 66 | c, err := New(ts.URL, &httpClient) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | if err := c.Kill("1234"); err != nil { 72 | t.Fatal(err) 73 | } 74 | } 75 | 76 | func TestClient_ReadTask(t *testing.T) { 77 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 | w.Write([]byte(`{"mem":22.0, "image": "busybox", "command": "echo hello", "cpu":0.5}`)) 79 | })) 80 | defer ts.Close() 81 | 82 | var httpClient http.Client 83 | c, err := New(ts.URL, &httpClient) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | task, err := c.Task("1234") 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | 92 | if task.TaskMem != 22 || task.Image != "busybox" || task.Command != "echo hello" || task.TaskCPUs != 0.5 { 93 | t.Fatal(errors.New("Unexpected task")) 94 | } 95 | } 96 | func TestClient_Version(t *testing.T) { 97 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | w.Write([]byte(version.Version)) 99 | })) 100 | defer ts.Close() 101 | 102 | var httpClient http.Client 103 | c, err := New(ts.URL, &httpClient) 104 | if err != nil { 105 | t.Fatal(err) 106 | } 107 | version, err := c.Version() 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | if len(version) == 0 { 113 | t.Fatal("Missing version") 114 | } 115 | } 116 | 117 | func TestClient_Sandbox(t *testing.T) { 118 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 119 | w.Write([]byte("remember remember the 5th of november\nthe gunpowder treason and plot.\nI see no reason the gunpowder treason should ever be forgot.\n")) 120 | })) 121 | defer ts.Close() 122 | 123 | var httpClient http.Client 124 | c, err := New(ts.URL, &httpClient) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | poem, err := c.Sandbox("1234", "poem") 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | 133 | if len(poem) == 0 { 134 | t.Fatal("Failed to get file") 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cmd/eremetic/eremetic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/braintree/manners" 11 | "github.com/prometheus/client_golang/prometheus" 12 | 13 | "github.com/eremetic-framework/eremetic" 14 | "github.com/eremetic-framework/eremetic/boltdb" 15 | "github.com/eremetic-framework/eremetic/config" 16 | "github.com/eremetic-framework/eremetic/mesos" 17 | "github.com/eremetic-framework/eremetic/metrics" 18 | "github.com/eremetic-framework/eremetic/server" 19 | "github.com/eremetic-framework/eremetic/version" 20 | "github.com/eremetic-framework/eremetic/zk" 21 | ) 22 | 23 | func setup() *config.Config { 24 | cfg := config.DefaultConfig() 25 | config.ReadConfigFile(cfg, config.GetConfigFilePath()) 26 | config.ReadEnvironment(cfg) 27 | 28 | return cfg 29 | } 30 | 31 | func setupLogging(logFormat, logLevel string) { 32 | if logFormat == "json" { 33 | logrus.SetFormatter(&logrus.JSONFormatter{}) 34 | } 35 | level, err := logrus.ParseLevel(logLevel) 36 | if err != nil { 37 | level = logrus.InfoLevel 38 | } 39 | logrus.SetLevel(level) 40 | } 41 | 42 | func getSchedulerSettings(config *config.Config) *mesos.Settings { 43 | return &mesos.Settings{ 44 | MaxQueueSize: config.QueueSize, 45 | Master: config.Master, 46 | FrameworkID: config.FrameworkID, 47 | CredentialFile: config.CredentialsFile, 48 | Name: config.Name, 49 | User: config.User, 50 | MessengerAddress: config.MessengerAddress, 51 | MessengerPort: uint16(config.MessengerPort), 52 | Checkpoint: config.Checkpoint, 53 | FailoverTimeout: config.FailoverTimeout, 54 | } 55 | } 56 | 57 | func main() { 58 | if len(os.Args) == 2 && os.Args[1] == "--version" { 59 | fmt.Println(version.Version) 60 | os.Exit(0) 61 | } 62 | config := setup() 63 | 64 | setupLogging(config.LogFormat, config.LogLevel) 65 | 66 | metrics.RegisterMetrics(prometheus.DefaultRegisterer) 67 | 68 | db, err := NewDB(config.DatabaseDriver, config.DatabasePath) 69 | if err != nil { 70 | logrus.WithError(err).Fatal("Unable to set up database.") 71 | } 72 | defer db.Close() 73 | 74 | settings := getSchedulerSettings(config) 75 | sched := mesos.NewScheduler(settings, db) 76 | 77 | go func() { 78 | sched.Run() 79 | manners.Close() 80 | }() 81 | 82 | // Catch interrupt 83 | go func() { 84 | c := make(chan os.Signal, 1) 85 | signal.Notify(c, os.Interrupt, os.Kill) 86 | s := <-c 87 | if s != os.Interrupt && s != os.Kill { 88 | return 89 | } 90 | 91 | logrus.Info("Eremetic is shutting down") 92 | sched.Stop() 93 | }() 94 | 95 | router := server.NewRouter(sched, config, db) 96 | 97 | bind := fmt.Sprintf("%s:%d", config.Address, config.Port) 98 | 99 | logrus.WithFields(logrus.Fields{ 100 | "version": version.Version, 101 | "address": config.Address, 102 | "port": config.Port, 103 | }).Infof("Launching Eremetic version %s!\nListening to %s", version.Version, bind) 104 | 105 | err = manners.ListenAndServe(bind, router) 106 | if err != nil { 107 | logrus.WithError(err).Fatal("Unrecoverable error") 108 | } 109 | } 110 | 111 | // NewDB Is used to create a new database driver based on settings. 112 | func NewDB(driver string, location string) (eremetic.TaskDB, error) { 113 | switch driver { 114 | case "boltdb": 115 | return boltdb.NewTaskDB(location) 116 | case "zk": 117 | return zk.NewTaskDB(location) 118 | } 119 | return nil, errors.New("invalid driver") 120 | } 121 | -------------------------------------------------------------------------------- /server/static/images/ram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/eremetic-framework/eremetic" 11 | "github.com/eremetic-framework/eremetic/api" 12 | ) 13 | 14 | // Client is used for communicating with an Eremetic server. 15 | type Client struct { 16 | httpClient *http.Client 17 | endpoint string 18 | } 19 | 20 | // New returns a new instance of a Client. 21 | func New(endpoint string, client *http.Client) (*Client, error) { 22 | return &Client{ 23 | httpClient: client, 24 | endpoint: endpoint, 25 | }, nil 26 | } 27 | 28 | // AddTask sends a request for a new task to be scheduled. 29 | func (c *Client) AddTask(r api.RequestV1) error { 30 | var buf bytes.Buffer 31 | 32 | err := json.NewEncoder(&buf).Encode(r) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | req, err := http.NewRequest("POST", c.endpoint+"/api/v1/task", &buf) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | _, err = c.httpClient.Do(req) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // Task returns a task with a given ID. 51 | func (c *Client) Task(id string) (*eremetic.Task, error) { 52 | req, err := http.NewRequest("GET", c.endpoint+"/api/v1/task/"+id, nil) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | resp, err := c.httpClient.Do(req) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | var task api.TaskV1 63 | 64 | err = json.NewDecoder(resp.Body).Decode(&task) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | t := api.TaskFromV1(&task) 70 | 71 | return &t, nil 72 | } 73 | 74 | // Tasks returns all current tasks. 75 | func (c *Client) Tasks() ([]eremetic.Task, error) { 76 | req, err := http.NewRequest("GET", c.endpoint+"/api/v1/task", nil) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | resp, err := c.httpClient.Do(req) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | var tasks []api.TaskV1 87 | 88 | err = json.NewDecoder(resp.Body).Decode(&tasks) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | taskSlice := []eremetic.Task{} 94 | for _, t := range tasks { 95 | taskSlice = append(taskSlice, api.TaskFromV1(&t)) 96 | } 97 | 98 | return taskSlice, nil 99 | } 100 | 101 | // Sandbox returns a sandbox resource for a given task. 102 | func (c *Client) Sandbox(taskID, file string) ([]byte, error) { 103 | u := fmt.Sprintf("%s/api/v1/task/%s/%s", c.endpoint, taskID, file) 104 | req, err := http.NewRequest("GET", u, nil) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | resp, err := c.httpClient.Do(req) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | b, err := ioutil.ReadAll(resp.Body) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | return b, nil 120 | } 121 | 122 | // Version returns the version of the Eremetic server. 123 | func (c *Client) Version() (string, error) { 124 | u := fmt.Sprintf("%s/api/v1/version", c.endpoint) 125 | req, err := http.NewRequest("GET", u, nil) 126 | if err != nil { 127 | return "", err 128 | } 129 | 130 | resp, err := c.httpClient.Do(req) 131 | if err != nil { 132 | return "", err 133 | } 134 | 135 | b, err := ioutil.ReadAll(resp.Body) 136 | if err != nil { 137 | return "", err 138 | } 139 | 140 | return string(b), nil 141 | } 142 | 143 | // Kill a running task 144 | func (c *Client) Kill(taskID string) error { 145 | u := fmt.Sprintf("%s/api/v1/task/%s/kill", c.endpoint, taskID) 146 | req, err := http.NewRequest("POST", u, nil) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | resp, err := c.httpClient.Do(req) 152 | if err != nil { 153 | return err 154 | } 155 | if resp.StatusCode != http.StatusAccepted { 156 | return fmt.Errorf("Unexpected status code `%s`", resp.Status) 157 | } 158 | 159 | return nil 160 | } 161 | -------------------------------------------------------------------------------- /mesos/match.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | ogle "github.com/jacobsa/oglematchers" 9 | "github.com/mesos/mesos-go/api/v0/mesosproto" 10 | 11 | "time" 12 | 13 | "github.com/eremetic-framework/eremetic" 14 | ) 15 | 16 | type resourceMatcher struct { 17 | name string 18 | value float64 19 | } 20 | 21 | type attributeMatcher struct { 22 | constraint eremetic.AgentConstraint 23 | } 24 | 25 | type availabilityMatcher struct { 26 | time.Time 27 | } 28 | 29 | func (m *resourceMatcher) Matches(o interface{}) error { 30 | offer := o.(*mesosproto.Offer) 31 | err := errors.New("") 32 | 33 | for _, res := range offer.Resources { 34 | if res.GetName() == m.name { 35 | if res.GetType() != mesosproto.Value_SCALAR { 36 | return err 37 | } 38 | 39 | if res.Scalar.GetValue() >= m.value { 40 | return nil 41 | } 42 | 43 | return err 44 | } 45 | } 46 | return err 47 | } 48 | 49 | func (m *resourceMatcher) Description() string { 50 | return fmt.Sprintf("%f of scalar resource %s", m.value, m.name) 51 | } 52 | 53 | func cpuAvailable(v float64) ogle.Matcher { 54 | return &resourceMatcher{"cpus", v} 55 | } 56 | 57 | func memoryAvailable(v float64) ogle.Matcher { 58 | return &resourceMatcher{"mem", v} 59 | } 60 | 61 | func availabilityMatch(matchTime time.Time) ogle.Matcher { 62 | return &availabilityMatcher{matchTime} 63 | } 64 | 65 | func (m *attributeMatcher) Matches(o interface{}) error { 66 | offer := o.(*mesosproto.Offer) 67 | 68 | for _, attr := range offer.Attributes { 69 | if attr.GetName() == m.constraint.AttributeName { 70 | if attr.GetType() != mesosproto.Value_TEXT || 71 | attr.Text.GetValue() != m.constraint.AttributeValue { 72 | return errors.New("") 73 | } 74 | return nil 75 | } 76 | } 77 | 78 | return errors.New("") 79 | } 80 | 81 | func (m *availabilityMatcher) Matches(o interface{}) error { 82 | offer := o.(*mesosproto.Offer) 83 | 84 | if offer.Unavailability == nil { 85 | return nil 86 | } 87 | 88 | if start := offer.Unavailability.GetStart(); start != nil && m.UnixNano() >= *start.Nanoseconds { 89 | if duration := offer.Unavailability.GetDuration(); duration == nil { 90 | return errors.New("node is on indefinite period of maintenance") 91 | } else if m.UnixNano() <= *start.Nanoseconds+*duration.Nanoseconds { 92 | return errors.New("node is currently in maintenance mode") 93 | } 94 | } 95 | return nil 96 | } 97 | 98 | func (m *availabilityMatcher) Description() string { 99 | return fmt.Sprintf("availability matcher") 100 | } 101 | 102 | func (m *attributeMatcher) Description() string { 103 | return fmt.Sprintf("agent attribute constraint %s=%s", 104 | m.constraint.AttributeName, 105 | m.constraint.AttributeValue, 106 | ) 107 | } 108 | 109 | func attributeMatch(agentConstraints []eremetic.AgentConstraint) ogle.Matcher { 110 | var submatchers []ogle.Matcher 111 | for _, constraint := range agentConstraints { 112 | submatchers = append(submatchers, &attributeMatcher{constraint}) 113 | } 114 | return ogle.AllOf(submatchers...) 115 | } 116 | 117 | func createMatcher(task eremetic.Task) ogle.Matcher { 118 | return ogle.AllOf( 119 | cpuAvailable(task.TaskCPUs), 120 | memoryAvailable(task.TaskMem), 121 | attributeMatch(task.AgentConstraints), 122 | availabilityMatch(time.Now()), 123 | ) 124 | } 125 | 126 | func matches(matcher ogle.Matcher, o interface{}) bool { 127 | err := matcher.Matches(o) 128 | return err == nil 129 | } 130 | 131 | func matchOffer(task eremetic.Task, offers []*mesosproto.Offer) (*mesosproto.Offer, []*mesosproto.Offer) { 132 | var matcher = createMatcher(task) 133 | for i, off := range offers { 134 | if matches(matcher, off) { 135 | offers[i] = offers[len(offers)-1] 136 | offers = offers[:len(offers)-1] 137 | return off, offers 138 | } 139 | logrus.WithFields(logrus.Fields{ 140 | "offer_id": off.Id.GetValue(), 141 | "matcher": matcher.Description(), 142 | "task_id": task.ID, 143 | }).Debug("Unable to match offer") 144 | } 145 | return nil, offers 146 | } 147 | -------------------------------------------------------------------------------- /server/static/css/style.css: -------------------------------------------------------------------------------- 1 | .logo.eremetic { 2 | width: 10em !important; 3 | } 4 | 5 | .error_message { 6 | float: left; 7 | left: 35em; 8 | top: 5em; 9 | } 10 | 11 | .ui.list > .item { 12 | display: flex !important; 13 | } 14 | 15 | .main.container { 16 | padding-top: 100px; 17 | } 18 | 19 | .container_section { 20 | padding-top: 20px; 21 | } 22 | 23 | .column .header { 24 | padding-bottom: 40px; 25 | } 26 | 27 | .ui.cpu.icon.svg { 28 | background: url("../images/cpu.svg"); 29 | background-size: 100% 100%; 30 | } 31 | .ui.ram.icon.svg { 32 | background: url("../images/ram.svg"); 33 | background-size: 100% 100%; 34 | } 35 | 36 | .ui.icon.input>i.icon.svg { 37 | width: 20px; 38 | height: 20px; 39 | top: 10px; 40 | right: 10px; 41 | } 42 | 43 | .list .item .label { 44 | margin-right: 1em; 45 | text-align: center; 46 | width: 10em; 47 | } 48 | 49 | .label.task_staging { 50 | background-color: #00b5ad !important; 51 | border-color: #00b5ad !important; 52 | color: #fff !important; 53 | } 54 | 55 | .label.task_running { 56 | background-color: #16ab39 !important; 57 | border-color: #16ab39 !important; 58 | color: #fff !important; 59 | } 60 | 61 | .label.task_failed { 62 | background-color: #db2828 !important; 63 | border-color: #db2828 !important; 64 | color: #fff !important; 65 | } 66 | 67 | .label.task_lost { 68 | background-color: #a333c8 !important; 69 | border-color: #a333c8 !important; 70 | color: #fff !important; 71 | } 72 | 73 | .label.task_killed { 74 | background-color: #f2711c !important; 75 | border-color: #f2711c !important; 76 | color: #fff !important; 77 | } 78 | 79 | .label.task_finished { 80 | background-color: #21ba45 !important; 81 | border-color: #21ba45 !important; 82 | color: #fff !important; 83 | } 84 | 85 | @font-face { 86 | font-family: "fontcustom"; 87 | src: url("./themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.eot"); 88 | src: url("./themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.eot?#iefix") format("embedded-opentype"), 89 | url("./themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.woff") format("woff"), 90 | url("./themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.ttf") format("truetype"), 91 | url("./themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.svg#fontcustom") format("svg"); 92 | font-weight: normal; 93 | font-style: normal; 94 | } 95 | 96 | @media screen and (-webkit-min-device-pixel-ratio:0) { 97 | @font-face { 98 | font-family: "fontcustom"; 99 | src: url("./themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.svg#fontcustom") format("svg"); 100 | } 101 | } 102 | 103 | [data-icon]:before { content: attr(data-icon); } 104 | 105 | [data-icon]:before, 106 | .docker:before { 107 | display: inline-block; 108 | font-family: "fontcustom"; 109 | font-style: normal; 110 | font-weight: normal; 111 | font-variant: normal; 112 | line-height: 1; 113 | text-decoration: inherit; 114 | text-rendering: optimizeLegibility; 115 | text-transform: none; 116 | -moz-osx-font-smoothing: grayscale; 117 | -webkit-font-smoothing: antialiased; 118 | font-smoothing: antialiased; 119 | } 120 | 121 | .docker:before { content: "\f100"; } 122 | 123 | .ui.button.eremetic_teal { 124 | background-color: #0199c8; 125 | } 126 | .volumes .field input, 127 | .volumes_from .field input, 128 | .env .field input, 129 | .labels .field input, 130 | .uri .field input, 131 | .ports .field input, 132 | .ports .field select, 133 | .agent_constraints .field input { 134 | width: auto !important; 135 | } 136 | 137 | .icon.add { 138 | cursor: pointer; 139 | } 140 | 141 | .optional { 142 | font-style: italic; 143 | } 144 | 145 | p.gray { 146 | color: #cccccc; 147 | } 148 | 149 | #show_stdout { 150 | color: #777777; 151 | border: 1px solid #bbb; 152 | background-color: #dddddd; 153 | text-align: center; 154 | border-radius: 4px; 155 | margin-bottom: 1em; 156 | cursor: pointer; 157 | } 158 | 159 | #show_stdout:hover { 160 | background-color: #eeeeee; 161 | color: #999999; 162 | } 163 | 164 | #kill { 165 | cursor: pointer; 166 | } 167 | -------------------------------------------------------------------------------- /zk/zk_connection.go: -------------------------------------------------------------------------------- 1 | package zk 2 | 3 | import ( 4 | "github.com/samuel/go-zookeeper/zk" 5 | "github.com/stretchr/testify/mock" 6 | ) 7 | 8 | // mockConnection is an autogenerated mock type for the Connection type 9 | type mockConnection struct { 10 | mock.Mock 11 | } 12 | 13 | // Children provides a mock function with given fields: path 14 | func (_m *mockConnection) Children(path string) ([]string, *zk.Stat, error) { 15 | ret := _m.Called(path) 16 | 17 | var r0 []string 18 | if rf, ok := ret.Get(0).(func(string) []string); ok { 19 | r0 = rf(path) 20 | } else { 21 | if ret.Get(0) != nil { 22 | r0 = ret.Get(0).([]string) 23 | } 24 | } 25 | 26 | var r1 *zk.Stat 27 | if rf, ok := ret.Get(1).(func(string) *zk.Stat); ok { 28 | r1 = rf(path) 29 | } else { 30 | if ret.Get(1) != nil { 31 | r1 = ret.Get(1).(*zk.Stat) 32 | } 33 | } 34 | 35 | var r2 error 36 | if rf, ok := ret.Get(2).(func(string) error); ok { 37 | r2 = rf(path) 38 | } else { 39 | r2 = ret.Error(2) 40 | } 41 | 42 | return r0, r1, r2 43 | } 44 | 45 | // Close provides a mock function with given fields: 46 | func (_m *mockConnection) Close() { 47 | _m.Called() 48 | } 49 | 50 | // Create provides a mock function with given fields: path, data, flags, acl 51 | func (_m *mockConnection) Create(path string, data []byte, flags int32, acl []zk.ACL) (string, error) { 52 | ret := _m.Called(path, data, flags, acl) 53 | 54 | var r0 string 55 | if rf, ok := ret.Get(0).(func(string, []byte, int32, []zk.ACL) string); ok { 56 | r0 = rf(path, data, flags, acl) 57 | } else { 58 | r0 = ret.Get(0).(string) 59 | } 60 | 61 | var r1 error 62 | if rf, ok := ret.Get(1).(func(string, []byte, int32, []zk.ACL) error); ok { 63 | r1 = rf(path, data, flags, acl) 64 | } else { 65 | r1 = ret.Error(1) 66 | } 67 | 68 | return r0, r1 69 | } 70 | 71 | // Delete provides a mock function with given fields: path, n 72 | func (_m *mockConnection) Delete(path string, n int32) error { 73 | ret := _m.Called(path, n) 74 | 75 | var r0 error 76 | if rf, ok := ret.Get(0).(func(string, int32) error); ok { 77 | r0 = rf(path, n) 78 | } else { 79 | r0 = ret.Error(0) 80 | } 81 | 82 | return r0 83 | } 84 | 85 | // Exists provides a mock function with given fields: path 86 | func (_m *mockConnection) Exists(path string) (bool, *zk.Stat, error) { 87 | ret := _m.Called(path) 88 | 89 | var r0 bool 90 | if rf, ok := ret.Get(0).(func(string) bool); ok { 91 | r0 = rf(path) 92 | } else { 93 | r0 = ret.Get(0).(bool) 94 | } 95 | 96 | var r1 *zk.Stat 97 | if rf, ok := ret.Get(1).(func(string) *zk.Stat); ok { 98 | r1 = rf(path) 99 | } else { 100 | if ret.Get(1) != nil { 101 | r1 = ret.Get(1).(*zk.Stat) 102 | } 103 | } 104 | 105 | var r2 error 106 | if rf, ok := ret.Get(2).(func(string) error); ok { 107 | r2 = rf(path) 108 | } else { 109 | r2 = ret.Error(2) 110 | } 111 | 112 | return r0, r1, r2 113 | } 114 | 115 | // Get provides a mock function with given fields: path 116 | func (_m *mockConnection) Get(path string) ([]byte, *zk.Stat, error) { 117 | ret := _m.Called(path) 118 | 119 | var r0 []byte 120 | if rf, ok := ret.Get(0).(func(string) []byte); ok { 121 | r0 = rf(path) 122 | } else { 123 | if ret.Get(0) != nil { 124 | r0 = ret.Get(0).([]byte) 125 | } 126 | } 127 | 128 | var r1 *zk.Stat 129 | if rf, ok := ret.Get(1).(func(string) *zk.Stat); ok { 130 | r1 = rf(path) 131 | } else { 132 | if ret.Get(1) != nil { 133 | r1 = ret.Get(1).(*zk.Stat) 134 | } 135 | } 136 | 137 | var r2 error 138 | if rf, ok := ret.Get(2).(func(string) error); ok { 139 | r2 = rf(path) 140 | } else { 141 | r2 = ret.Error(2) 142 | } 143 | 144 | return r0, r1, r2 145 | } 146 | 147 | // Set provides a mock function with given fields: path, data, version 148 | func (_m *mockConnection) Set(path string, data []byte, version int32) (*zk.Stat, error) { 149 | ret := _m.Called(path, data, version) 150 | 151 | var r0 *zk.Stat 152 | if rf, ok := ret.Get(0).(func(string, []byte, int32) *zk.Stat); ok { 153 | r0 = rf(path, data, version) 154 | } else { 155 | if ret.Get(0) != nil { 156 | r0 = ret.Get(0).(*zk.Stat) 157 | } 158 | } 159 | 160 | var r1 error 161 | if rf, ok := ret.Get(1).(func(string, []byte, int32) error); ok { 162 | r1 = rf(path, data, version) 163 | } else { 164 | r1 = ret.Error(1) 165 | } 166 | 167 | return r0, r1 168 | } 169 | -------------------------------------------------------------------------------- /boltdb/boltdb.go: -------------------------------------------------------------------------------- 1 | package boltdb 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/boltdb/bolt" 11 | 12 | "github.com/eremetic-framework/eremetic" 13 | ) 14 | 15 | // connection defines the functions needed to interact with a bolt database 16 | type connection interface { 17 | Close() error 18 | Update(func(*bolt.Tx) error) error 19 | View(func(*bolt.Tx) error) error 20 | Path() string 21 | } 22 | 23 | // connector assists in opening a boltdb connection 24 | type connector interface { 25 | Open(path string) (connection, error) 26 | } 27 | 28 | type defaultConnector struct{} 29 | 30 | func (b defaultConnector) Open(file string) (connection, error) { 31 | os.MkdirAll(filepath.Dir(file), 0755) 32 | 33 | return bolt.Open(file, 0600, nil) 34 | } 35 | 36 | // TaskDB is a boltdb implementation of the task database. 37 | type TaskDB struct { 38 | conn connection 39 | } 40 | 41 | // NewTaskDB returns a new instance of TaskDB. 42 | func NewTaskDB(file string) (*TaskDB, error) { 43 | return newCustomTaskDB(defaultConnector{}, file) 44 | } 45 | 46 | func newCustomTaskDB(c connector, file string) (*TaskDB, error) { 47 | if file == "" { 48 | return nil, errors.New("missing boltdb database location") 49 | } 50 | 51 | conn, err := c.Open(file) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | err = conn.Update(func(tx *bolt.Tx) error { 57 | _, err := tx.CreateBucketIfNotExists([]byte("tasks")) 58 | return err 59 | }) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return &TaskDB{conn: conn}, nil 65 | } 66 | 67 | // Close is used to Close the database 68 | func (db *TaskDB) Close() { 69 | if db.conn != nil { 70 | db.conn.Close() 71 | } 72 | } 73 | 74 | // Clean is used to delete the tasks bucket 75 | func (db *TaskDB) Clean() error { 76 | return db.conn.Update(func(tx *bolt.Tx) error { 77 | return tx.DeleteBucket([]byte("tasks")) 78 | }) 79 | } 80 | 81 | // PutTask stores a requested task in the database 82 | func (db *TaskDB) PutTask(task *eremetic.Task) error { 83 | return db.conn.Update(func(tx *bolt.Tx) error { 84 | b, err := tx.CreateBucketIfNotExists([]byte("tasks")) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | encoded, err := eremetic.Encode(task) 90 | if err != nil { 91 | logrus.WithError(err).Error("Unable to encode task to byte-array.") 92 | return err 93 | } 94 | 95 | return b.Put([]byte(task.ID), encoded) 96 | }) 97 | } 98 | 99 | // ReadTask fetches a task from the database and applies a mask to the 100 | // MaskedEnvironment field 101 | func (db *TaskDB) ReadTask(id string) (eremetic.Task, error) { 102 | task, err := db.ReadUnmaskedTask(id) 103 | 104 | eremetic.ApplyMask(&task) 105 | 106 | return task, err 107 | } 108 | 109 | // ReadUnmaskedTask fetches a task from the database and does not mask the 110 | // MaskedEnvironment field. 111 | // This function should be considered internal to Eremetic, and is used where 112 | // we need to fetch a task and then re-save it to the database. It should not 113 | // be returned to the API. 114 | func (db *TaskDB) ReadUnmaskedTask(id string) (eremetic.Task, error) { 115 | var task eremetic.Task 116 | 117 | err := db.conn.View(func(tx *bolt.Tx) error { 118 | b := tx.Bucket([]byte("tasks")) 119 | if b == nil { 120 | return bolt.ErrBucketNotFound 121 | } 122 | v := b.Get([]byte(id)) 123 | json.Unmarshal(v, &task) 124 | return nil 125 | }) 126 | 127 | return task, err 128 | } 129 | 130 | // DeleteTask deletes a task matching the given id. 131 | func (db *TaskDB) DeleteTask(id string) error { 132 | return db.conn.Update(func(tx *bolt.Tx) error { 133 | b, err := tx.CreateBucketIfNotExists([]byte("tasks")) 134 | if err != nil { 135 | return err 136 | } 137 | return b.Delete([]byte(id)) 138 | }) 139 | } 140 | 141 | // ListTasks returns all tasks. 142 | func (db *TaskDB) ListTasks(filter *eremetic.TaskFilter) ([]*eremetic.Task, error) { 143 | tasks := []*eremetic.Task{} 144 | 145 | err := db.conn.View(func(tx *bolt.Tx) error { 146 | b := tx.Bucket([]byte("tasks")) 147 | if b == nil { 148 | return bolt.ErrBucketNotFound 149 | } 150 | b.ForEach(func(_, v []byte) error { 151 | var task eremetic.Task 152 | json.Unmarshal(v, &task) 153 | eremetic.ApplyMask(&task) 154 | if filter.Match(&task) { 155 | tasks = append(tasks, &task) 156 | } 157 | return nil 158 | }) 159 | return nil 160 | }) 161 | 162 | return tasks, err 163 | } 164 | -------------------------------------------------------------------------------- /zk/zookeeper.go: -------------------------------------------------------------------------------- 1 | package zk 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/samuel/go-zookeeper/zk" 12 | "github.com/sirupsen/logrus" 13 | 14 | "github.com/eremetic-framework/eremetic" 15 | ) 16 | 17 | // connection wraps a zk.Conn struct for testability 18 | type connection interface { 19 | Close() 20 | Create(path string, data []byte, flags int32, acl []zk.ACL) (string, error) 21 | Delete(path string, n int32) error 22 | Exists(path string) (bool, *zk.Stat, error) 23 | Get(path string) ([]byte, *zk.Stat, error) 24 | Set(path string, data []byte, version int32) (*zk.Stat, error) 25 | Children(path string) ([]string, *zk.Stat, error) 26 | } 27 | 28 | // connector helps create a zookeeper connection 29 | type connector interface { 30 | Connect(path string) (connection, error) 31 | } 32 | 33 | // TaskDB is a Zookeeper implementation of the task database. 34 | type TaskDB struct { 35 | conn connection 36 | path string 37 | } 38 | 39 | type defaultConnector struct{} 40 | 41 | func (z defaultConnector) Connect(zksStr string) (connection, error) { 42 | zks := strings.Split(zksStr, ",") 43 | conn, _, err := zk.Connect(zks, time.Second) 44 | 45 | return conn, err 46 | } 47 | 48 | func parsePath(zkpath string) (string, string, error) { 49 | u, err := url.Parse(zkpath) 50 | if err != nil { 51 | return "", "", err 52 | } 53 | 54 | path := strings.TrimRight(u.Path, "/") 55 | return u.Host, path, nil 56 | } 57 | 58 | // NewTaskDB returns a new instance of a Zookeeper TaskDB. 59 | func NewTaskDB(zk string) (*TaskDB, error) { 60 | return newCustomTaskDB(defaultConnector{}, zk) 61 | } 62 | 63 | func newCustomTaskDB(c connector, path string) (*TaskDB, error) { 64 | if path == "" { 65 | return nil, errors.New("Missing ZK path") 66 | } 67 | 68 | servers, path, err := parsePath(path) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | conn, err := c.Connect(servers) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | exists, _, err := conn.Exists(path) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | if !exists { 84 | flags := int32(0) 85 | acl := zk.WorldACL(zk.PermAll) 86 | 87 | _, err = conn.Create(path, nil, flags, acl) 88 | if err != nil { 89 | logrus.WithError(err).Error("Unable to create node.") 90 | return nil, err 91 | } 92 | } 93 | 94 | return &TaskDB{ 95 | conn: conn, 96 | path: path, 97 | }, nil 98 | } 99 | 100 | // Close closes the connection to the database. 101 | func (z *TaskDB) Close() { 102 | z.conn.Close() 103 | } 104 | 105 | // Clean removes all tasks from the database. 106 | func (z *TaskDB) Clean() error { 107 | path := fmt.Sprintf("%s/", z.path) 108 | return z.conn.Delete(path, -1) 109 | } 110 | 111 | // PutTask adds a new task to the database. 112 | func (z *TaskDB) PutTask(task *eremetic.Task) error { 113 | path := fmt.Sprintf("%s/%s", z.path, task.ID) 114 | 115 | encode, err := eremetic.Encode(task) 116 | if err != nil { 117 | logrus.WithError(err).Error("Unable to encode task to byte-array.") 118 | return err 119 | } 120 | 121 | exists, stat, err := z.conn.Exists(path) 122 | if err != nil { 123 | logrus.WithError(err).Error("Unable to check existence of database.") 124 | return err 125 | } 126 | 127 | if exists { 128 | _, err = z.conn.Set(path, encode, stat.Version) 129 | return err 130 | } 131 | 132 | flags := int32(0) 133 | acl := zk.WorldACL(zk.PermAll) 134 | _, err = z.conn.Create(path, encode, flags, acl) 135 | return err 136 | } 137 | 138 | // ReadTask returns a task with a given id, or an error if not found. 139 | func (z *TaskDB) ReadTask(id string) (eremetic.Task, error) { 140 | task, err := z.ReadUnmaskedTask(id) 141 | 142 | eremetic.ApplyMask(&task) 143 | 144 | return task, err 145 | } 146 | 147 | // ReadUnmaskedTask returns a task with all its environment variables unmasked. 148 | func (z *TaskDB) ReadUnmaskedTask(id string) (eremetic.Task, error) { 149 | var task eremetic.Task 150 | path := fmt.Sprintf("%s/%s", z.path, id) 151 | 152 | bytes, _, err := z.conn.Get(path) 153 | if err != nil { 154 | logrus.WithError(err).Debug("Unable to Get from zk.") 155 | } 156 | uError := json.Unmarshal(bytes, &task) 157 | if uError != nil { 158 | logrus.WithError(uError).Debug(fmt.Sprintf("Unable to Unmarshal %s.", string(bytes))) 159 | } 160 | 161 | return task, err 162 | 163 | } 164 | 165 | // DeleteTask deletes a task with the matching ID from zookeeper 166 | func (z *TaskDB) DeleteTask(id string) error { 167 | path := fmt.Sprintf("%s/%s", z.path, id) 168 | _, stat, err := z.conn.Exists(path) 169 | if err != nil { 170 | logrus.WithError(err).Error("Unable to check existence of database.") 171 | return err 172 | } 173 | err = z.conn.Delete(path, stat.Version) 174 | return err 175 | } 176 | 177 | // ListTasks returns all tasks. 178 | func (z *TaskDB) ListTasks(filter *eremetic.TaskFilter) ([]*eremetic.Task, error) { 179 | tasks := []*eremetic.Task{} 180 | paths, _, _ := z.conn.Children(z.path) 181 | for _, p := range paths { 182 | t, err := z.ReadTask(p) 183 | if err != nil { 184 | logrus.WithError(err).Error("Unable to read task from database, skipping") 185 | continue 186 | } 187 | eremetic.ApplyMask(&t) 188 | if filter.Match(&t) { 189 | tasks = append(tasks, &t) 190 | } 191 | } 192 | return tasks, nil 193 | } 194 | -------------------------------------------------------------------------------- /server/static/css/themes/default/assets/fonts/fontcustom_cb066050d5b786186aa0e7f121427d8b.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Created by FontForge 20120731 at Thu May 22 15:12:55 2014 9 | By Wes Bos 10 | Created by Wes Bos with FontForge 2.0 (http://fontforge.sf.net) 11 | 12 | 13 | 14 | 27 | 28 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /server/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Eremetic 14 | 15 | 16 |

26 |
27 |
28 | 29 |
30 |

Required

31 |
32 |
33 |
34 |
Image
35 | 36 | 37 |
38 |
39 |
Command
40 | 41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 |
CPUs
49 | 50 | 51 |
52 |
53 |
Memory (MiB)
54 | 55 | 56 |
57 |
58 |
59 |

Optional

60 |
61 |
62 |
63 | Callback URL 64 |
65 | 66 | 67 |
68 |
69 |
70 |
71 |
72 | 76 |
77 |
78 | 82 |
83 |
84 |
85 |
86 |
87 |
88 | 92 |
93 |
94 | 98 |
99 |
100 |
101 |
102 |
103 |
104 | 108 |
109 |
110 | 114 |
115 |
116 |
117 |
118 |
119 |
120 | 124 |
125 |
126 |
127 |
128 | 129 |
130 | 131 |
132 |
133 |
134 |
135 | 136 | 137 | -------------------------------------------------------------------------------- /misc/swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Eremetic 4 | description: Run one-off tasks on mesos 5 | version: "1.0.0" 6 | schemes: 7 | - https 8 | basePath: / 9 | produces: 10 | - application/json 11 | paths: 12 | /api/v1/task: 13 | get: 14 | summary: List running tasks 15 | description: | 16 | List all running tasks, masking values in MaskedEnvironment. 17 | parameters: 18 | - in: query 19 | name: name 20 | schema: 21 | type: string 22 | required: false 23 | description: The name of the task. There can be several tasks with same name. 24 | - in: query 25 | name: state 26 | schema: 27 | type: string 28 | required: false 29 | description: The state of the task. Valid states are queued, active and terminated. 30 | Can be comma combined composite state e.g. "terminated,queued". The Default value 31 | is "active,queued". Empty string will return all states. 32 | responses: 33 | 200: 34 | description: Task details 35 | schema: 36 | $ref: '#/definitions/Task' 37 | default: 38 | description: Unexpected error 39 | post: 40 | summary: Launch a task 41 | consumes: 42 | - application/json 43 | description: | 44 | The task endpoint lets you launch a task on mesos by submitting a payload describing the task to be run. 45 | parameters: 46 | - name: Task definition 47 | in: body 48 | description: Details of task to launch 49 | required: true 50 | schema: 51 | $ref: '#/definitions/Task' 52 | responses: 53 | 202: 54 | description: Task ID 55 | schema: 56 | type: string 57 | default: 58 | description: Unexpected error 59 | /api/v1/task/{taskId}: 60 | get: 61 | summary: Get status of task 62 | description: | 63 | Check the status of task previously launched. 64 | parameters: 65 | - name: taskId 66 | in: path 67 | description: Task identifier 68 | required: true 69 | type: string 70 | responses: 71 | 200: 72 | description: Task details 73 | schema: 74 | $ref: '#/definitions/Task' 75 | 404: 76 | description: Task not found 77 | default: 78 | description: Unexpected error 79 | definitions: 80 | Volume: 81 | type: object 82 | properties: 83 | container_path: 84 | type: string 85 | description: Path in container to mount at 86 | host_path: 87 | type: string 88 | description: Path on host to mount 89 | URI: 90 | type: object 91 | properties: 92 | uri: 93 | type: string 94 | description: URI to fetch 95 | extract: 96 | type: boolean 97 | description: True if the file should be extracted after being fetched 98 | executable: 99 | type: boolean 100 | description: True if the file should be flagged as executable 101 | cache: 102 | type: boolean 103 | description: True if the file should be cached 104 | Tasks: 105 | type: array 106 | items: 107 | $ref: '#/definitions/Task' 108 | Task: 109 | type: object 110 | required: 111 | - image 112 | - command 113 | - cpu 114 | - mem 115 | properties: 116 | id: 117 | type: string 118 | readOnly: true 119 | description: ID of task 120 | name: 121 | type: string 122 | description: Name of task 123 | image: 124 | type: string 125 | description: Full tag or hash of container to run 126 | command: 127 | type: string 128 | description: Command to run in the docker container 129 | force_pull_image: 130 | type: boolean 131 | description: Docker image will be pulled before each task launch 132 | privileged: 133 | type: boolean 134 | description: Docker will run the container in 'privileged' mode giving it all capabilities 135 | network: 136 | type: string 137 | description: Network mode to pass to the container 138 | dns: 139 | type: string 140 | description: DNS to be used by the container 141 | args: 142 | type: array 143 | items: 144 | type: string 145 | description: arguements to pass to the docker container entrypoint 146 | cpu: 147 | type: number 148 | description: Fractions of a CPU to request 149 | mem: 150 | type: number 151 | description: memory to use (MiB) 152 | volumes: 153 | type: array 154 | items: 155 | $ref: '#/definitions/Volume' 156 | volumes_from: 157 | type: array 158 | items: 159 | type: string 160 | description: container names to get volumes from 161 | fetch: 162 | type: array 163 | items: 164 | $ref: '#/definitions/URI' 165 | env: 166 | type: object 167 | description: Environment variables to set 168 | masked_env: 169 | type: object 170 | description: Environment variables to set but that are masked in any GET request. 171 | labels: 172 | type: object 173 | description: Labels for the task 174 | callback_uri: 175 | type: string 176 | description: URL to post a callback to 177 | framework_id: 178 | type: string 179 | readOnly: true 180 | description: Framework ID used when launching task 181 | agent_id: 182 | type: string 183 | readOnly: true 184 | description: Id of slave where task is running 185 | hostname: 186 | type: string 187 | readOnly: true 188 | description: Name of host where task is launched 189 | retry: 190 | type: number 191 | readOnly: true 192 | description: Number of times a retry have been attempted 193 | status: 194 | type: array 195 | readOnly: true 196 | items: 197 | type: object 198 | properties: 199 | status: 200 | type: string 201 | description: Status identifier 202 | enum: 203 | - TASK_STAGING 204 | - TASK_RUNNING 205 | - TASK_FINISHED 206 | - TASK_FAILED 207 | - TASK_LOST 208 | - TASK_ERROR 209 | time: 210 | type: number 211 | description: Unix timestamp of status change 212 | -------------------------------------------------------------------------------- /mesos/match_test.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/mesos/mesos-go/api/v0/mesosproto" 8 | . "github.com/smartystreets/goconvey/convey" 9 | 10 | "github.com/eremetic-framework/eremetic" 11 | ) 12 | 13 | func TestMatch(t *testing.T) { 14 | offerA := offer("offer-a", 0.6, 200.0, 15 | unavailability(), 16 | textAttribute("role", "badassmofo"), 17 | textAttribute("node_name", "node1"), 18 | ) 19 | offerB := offer("offer-b", 1.8, 512.0, 20 | unavailability(), 21 | textAttribute("node_name", "node2"), 22 | ) 23 | offerC := offer("offer-c", 1.8, 512.0, 24 | unavailability( 25 | time.Now().UnixNano(), 26 | time.Unix(0, 0).Add(1*time.Hour).UnixNano(), 27 | ), 28 | textAttribute("node_name", "node3"), 29 | ) 30 | offerD := offer("offer-d", 1.8, 512.0, 31 | unavailability( 32 | time.Now().UnixNano(), 33 | ), 34 | textAttribute("node_name", "node4"), 35 | ) 36 | offerE := offer("offer-e", 1.8, 512.0, 37 | unavailability( 38 | time.Now().Add(-2*time.Hour).UnixNano(), 39 | time.Unix(0, 0).Add(1*time.Hour).UnixNano(), 40 | ), 41 | textAttribute("node_name", "node3"), 42 | ) 43 | 44 | Convey("CPUAvailable", t, func() { 45 | Convey("Above", func() { 46 | m := cpuAvailable(0.4) 47 | err := m.Matches(offerA) 48 | So(err, ShouldBeNil) 49 | }) 50 | 51 | Convey("Below", func() { 52 | m := cpuAvailable(0.8) 53 | err := m.Matches(offerA) 54 | So(err, ShouldNotBeNil) 55 | }) 56 | }) 57 | 58 | Convey("MemoryAvailable", t, func() { 59 | Convey("Above", func() { 60 | 61 | m := memoryAvailable(128.0) 62 | err := m.Matches(offerA) 63 | So(err, ShouldBeNil) 64 | }) 65 | 66 | Convey("Below", func() { 67 | m := memoryAvailable(256.0) 68 | err := m.Matches(offerA) 69 | So(err, ShouldNotBeNil) 70 | }) 71 | }) 72 | 73 | Convey("Maintenance node", t, func() { 74 | Convey("Does not match (Defined maintenance window)", func() { 75 | m := availabilityMatch(time.Now()) 76 | err := m.Matches(offerC) 77 | So(err, ShouldNotBeNil) 78 | }) 79 | 80 | Convey("Does not match (Undefined maintenance window)", func() { 81 | m := availabilityMatch(time.Now()) 82 | err := m.Matches(offerD) 83 | So(err, ShouldNotBeNil) 84 | }) 85 | 86 | Convey("Does match (maintenance window in past)", func() { 87 | m := availabilityMatch(time.Now()) 88 | err := m.Matches(offerE) 89 | So(err, ShouldBeNil) 90 | }) 91 | 92 | Convey("Does match the offer (task match)", func() { 93 | task := eremetic.Task{ 94 | TaskCPUs: 0.6, 95 | TaskMem: 128.0, 96 | } 97 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerC, offerD}) 98 | So(offer, ShouldEqual, offerA) 99 | So(others, ShouldHaveLength, 2) 100 | }) 101 | }) 102 | 103 | Convey("AttributeMatch", t, func() { 104 | Convey("Does match", func() { 105 | m := attributeMatch([]eremetic.AgentConstraint{ 106 | eremetic.AgentConstraint{ 107 | AttributeName: "node_name", 108 | AttributeValue: "node1", 109 | }, 110 | }) 111 | err := m.Matches(offerA) 112 | So(err, ShouldBeNil) 113 | }) 114 | Convey("Does not match", func() { 115 | m := attributeMatch([]eremetic.AgentConstraint{ 116 | eremetic.AgentConstraint{ 117 | AttributeName: "node_name", 118 | AttributeValue: "node2", 119 | }, 120 | }) 121 | err := m.Matches(offerA) 122 | So(err, ShouldNotBeNil) 123 | }) 124 | }) 125 | 126 | Convey("matchOffer", t, func() { 127 | Convey("Tasks without AgentConstraints", func() { 128 | Convey("Match", func() { 129 | task := eremetic.Task{ 130 | TaskCPUs: 0.8, 131 | TaskMem: 128.0, 132 | } 133 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerB}) 134 | 135 | So(offer, ShouldEqual, offerB) 136 | So(others, ShouldHaveLength, 1) 137 | So(others, ShouldContain, offerA) 138 | }) 139 | 140 | Convey("No match CPU", func() { 141 | task := eremetic.Task{ 142 | TaskCPUs: 2.0, 143 | TaskMem: 128.0, 144 | } 145 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerB}) 146 | 147 | So(offer, ShouldBeNil) 148 | So(others, ShouldHaveLength, 2) 149 | }) 150 | 151 | Convey("No match MEM", func() { 152 | task := eremetic.Task{ 153 | TaskCPUs: 0.2, 154 | TaskMem: 712.0, 155 | } 156 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerB}) 157 | 158 | So(offer, ShouldBeNil) 159 | So(others, ShouldHaveLength, 2) 160 | }) 161 | }) 162 | 163 | Convey("Tasks with AgentConstraints", func() { 164 | Convey("Match agent with attribute", func() { 165 | // Use task/mem constraints which match both offers. 166 | task := eremetic.Task{ 167 | TaskCPUs: 0.5, 168 | TaskMem: 128.0, 169 | AgentConstraints: []eremetic.AgentConstraint{ 170 | eremetic.AgentConstraint{ 171 | AttributeName: "node_name", 172 | AttributeValue: "node2", 173 | }, 174 | }, 175 | } 176 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerB}) 177 | 178 | So(offer, ShouldEqual, offerB) 179 | So(others, ShouldHaveLength, 1) 180 | So(others, ShouldContain, offerA) 181 | }) 182 | 183 | Convey("No matching slave with attribute", func() { 184 | // Use task/mem constraints which match both offers. 185 | task := eremetic.Task{ 186 | TaskCPUs: 0.5, 187 | TaskMem: 128.0, 188 | AgentConstraints: []eremetic.AgentConstraint{ 189 | eremetic.AgentConstraint{ 190 | AttributeName: "node_name", 191 | AttributeValue: "sherah", 192 | }, 193 | }, 194 | } 195 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerB}) 196 | 197 | So(offer, ShouldBeNil) 198 | So(others, ShouldHaveLength, 2) 199 | }) 200 | 201 | Convey("Match slave with multiple attributes", func() { 202 | // Build two new offers, both with the same role as offerA. 203 | offerC := offer("offer-c", 0.6, 200.0, 204 | unavailability(), 205 | textAttribute("role", "badassmofo"), 206 | textAttribute("node_name", "node3"), 207 | ) 208 | offerD := offer("offer-d", 0.6, 200.0, 209 | unavailability(), 210 | textAttribute("role", "badassmofo"), 211 | ) 212 | 213 | task := eremetic.Task{ 214 | TaskCPUs: 0.5, 215 | TaskMem: 128.0, 216 | AgentConstraints: []eremetic.AgentConstraint{ 217 | eremetic.AgentConstraint{ 218 | AttributeName: "role", 219 | AttributeValue: "badassmofo", 220 | }, 221 | eremetic.AgentConstraint{ 222 | AttributeName: "node_name", 223 | AttributeValue: "node3", 224 | }, 225 | }, 226 | } 227 | // Specifically add C last, our expected, so that we ensure 228 | // the other mocks do not match first. 229 | offer, others := matchOffer(task, []*mesosproto.Offer{offerA, offerD, offerC}) 230 | 231 | So(offer, ShouldEqual, offerC) 232 | So(others, ShouldHaveLength, 2) 233 | So(others, ShouldContain, offerA) 234 | So(others, ShouldContain, offerD) 235 | }) 236 | }) 237 | }) 238 | } 239 | -------------------------------------------------------------------------------- /api/api_v0.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/eremetic-framework/eremetic" 5 | ) 6 | 7 | // TaskV0 defines the deprecated json-structure for the properties of a scheduled task. 8 | type TaskV0 struct { 9 | TaskCPUs float64 `json:"task_cpus"` 10 | TaskMem float64 `json:"task_mem"` 11 | Command string `json:"command"` 12 | Args []string `json:"args"` 13 | User string `json:"user"` 14 | Environment map[string]string `json:"env"` 15 | MaskedEnvironment map[string]string `json:"masked_env"` 16 | Labels map[string]string `json:"labels"` 17 | Image string `json:"image"` 18 | Volumes []eremetic.Volume `json:"volumes"` 19 | Ports []eremetic.Port `json:"ports"` 20 | Status []eremetic.Status `json:"status"` 21 | ID string `json:"id"` 22 | Name string `json:"name"` 23 | Network string `json:"network"` 24 | DNS string `json:"dns"` 25 | FrameworkID string `json:"framework_id"` 26 | AgentID string `json:"slave_id"` 27 | AgentConstraints []eremetic.AgentConstraint `json:"slave_constraints"` 28 | Hostname string `json:"hostname"` 29 | Retry int `json:"retry"` 30 | CallbackURI string `json:"callback_uri"` 31 | SandboxPath string `json:"sandbox_path"` 32 | AgentIP string `json:"agent_ip"` 33 | AgentPort int32 `json:"agent_port"` 34 | ForcePullImage bool `json:"force_pull_image"` 35 | Privileged bool `json:"privileged"` 36 | FetchURIs []eremetic.URI `json:"fetch"` 37 | } 38 | 39 | // TaskV0FromTask is needed for Go versions < 1.8 40 | // In go 1.8, TaskV0(Task) would work instead 41 | func TaskV0FromTask(task *eremetic.Task) TaskV0 { 42 | return TaskV0{ 43 | TaskCPUs: task.TaskCPUs, 44 | TaskMem: task.TaskMem, 45 | Command: task.Command, 46 | Args: task.Args, 47 | User: task.User, 48 | Environment: task.Environment, 49 | MaskedEnvironment: task.MaskedEnvironment, 50 | Labels: task.Labels, 51 | Image: task.Image, 52 | Volumes: task.Volumes, 53 | Ports: task.Ports, 54 | Status: task.Status, 55 | ID: task.ID, 56 | Name: task.Name, 57 | Network: task.Network, 58 | DNS: task.DNS, 59 | FrameworkID: task.FrameworkID, 60 | AgentID: task.AgentID, 61 | AgentConstraints: task.AgentConstraints, 62 | Hostname: task.Hostname, 63 | Retry: task.Retry, 64 | CallbackURI: task.CallbackURI, 65 | SandboxPath: task.SandboxPath, 66 | AgentIP: task.AgentIP, 67 | AgentPort: task.AgentPort, 68 | ForcePullImage: task.ForcePullImage, 69 | Privileged: task.Privileged, 70 | FetchURIs: task.FetchURIs, 71 | } 72 | } 73 | 74 | // TaskFromV0 is needed for Go versions < 1.8 75 | // In go 1.8, Task(TaskV0) would work instead 76 | func TaskFromV0(task *TaskV0) eremetic.Task { 77 | return eremetic.Task{ 78 | TaskCPUs: task.TaskCPUs, 79 | TaskMem: task.TaskMem, 80 | Command: task.Command, 81 | Args: task.Args, 82 | User: task.User, 83 | Environment: task.Environment, 84 | MaskedEnvironment: task.MaskedEnvironment, 85 | Labels: task.Labels, 86 | Image: task.Image, 87 | Volumes: task.Volumes, 88 | Ports: task.Ports, 89 | Status: task.Status, 90 | ID: task.ID, 91 | Name: task.Name, 92 | Network: task.Network, 93 | DNS: task.DNS, 94 | FrameworkID: task.FrameworkID, 95 | AgentID: task.AgentID, 96 | AgentConstraints: task.AgentConstraints, 97 | Hostname: task.Hostname, 98 | Retry: task.Retry, 99 | CallbackURI: task.CallbackURI, 100 | SandboxPath: task.SandboxPath, 101 | AgentIP: task.AgentIP, 102 | AgentPort: task.AgentPort, 103 | ForcePullImage: task.ForcePullImage, 104 | Privileged: task.Privileged, 105 | FetchURIs: task.FetchURIs, 106 | } 107 | } 108 | 109 | // RequestV0 represents the old deprecated json-structure of a job request 110 | type RequestV0 struct { 111 | TaskCPUs float64 `json:"task_cpus"` 112 | TaskMem float64 `json:"task_mem"` 113 | DockerImage string `json:"docker_image"` 114 | Command string `json:"command"` 115 | Args []string `json:"args"` 116 | Volumes []eremetic.Volume `json:"volumes"` 117 | Ports []eremetic.Port `json:"ports"` 118 | Name string `json:"name"` 119 | Network string `json:"network"` 120 | DNS string `json:"dns"` 121 | Environment map[string]string `json:"env"` 122 | MaskedEnvironment map[string]string `json:"masked_env"` 123 | Labels map[string]string `json:"labels"` 124 | AgentConstraints []eremetic.AgentConstraint `json:"slave_constraints"` 125 | CallbackURI string `json:"callback_uri"` 126 | URIs []string `json:"uris"` 127 | Fetch []eremetic.URI `json:"fetch"` 128 | ForcePullImage bool `json:"force_pull_image"` 129 | Privileged bool `json:"privileged"` 130 | } 131 | 132 | // RequestFromV0 is needed for Go versions < 1.8 133 | // In go 1.8, Request(RequestV0) would work instead 134 | func RequestFromV0(req RequestV0) eremetic.Request { 135 | return eremetic.Request{ 136 | TaskCPUs: req.TaskCPUs, 137 | TaskMem: req.TaskMem, 138 | DockerImage: req.DockerImage, 139 | Command: req.Command, 140 | Args: req.Args, 141 | Volumes: req.Volumes, 142 | Ports: req.Ports, 143 | Name: req.Name, 144 | Network: req.Network, 145 | DNS: req.DNS, 146 | Environment: req.Environment, 147 | MaskedEnvironment: req.MaskedEnvironment, 148 | Labels: req.Labels, 149 | AgentConstraints: req.AgentConstraints, 150 | CallbackURI: req.CallbackURI, 151 | URIs: req.URIs, 152 | Fetch: req.Fetch, 153 | ForcePullImage: req.ForcePullImage, 154 | Privileged: req.Privileged, 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /api/api_v1.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/eremetic-framework/eremetic" 5 | ) 6 | 7 | // TaskV1 defines the API V1 json-structure for the properties of a scheduled task. 8 | type TaskV1 struct { 9 | TaskCPUs float64 `json:"cpu"` 10 | TaskMem float64 `json:"mem"` 11 | Command string `json:"command"` 12 | Args []string `json:"args"` 13 | User string `json:"user"` 14 | Environment map[string]string `json:"env"` 15 | MaskedEnvironment map[string]string `json:"masked_env"` 16 | Labels map[string]string `json:"labels"` 17 | Image string `json:"image"` 18 | Volumes []eremetic.Volume `json:"volumes"` 19 | VolumesFrom []string `json:"volumes_from"` 20 | Ports []eremetic.Port `json:"ports"` 21 | Status []eremetic.Status `json:"status"` 22 | ID string `json:"id"` 23 | Name string `json:"name"` 24 | Network string `json:"network"` 25 | DNS string `json:"dns"` 26 | FrameworkID string `json:"framework_id"` 27 | AgentID string `json:"agent_id"` 28 | AgentConstraints []eremetic.AgentConstraint `json:"agent_constraints"` 29 | Hostname string `json:"hostname"` 30 | Retry int `json:"retry"` 31 | CallbackURI string `json:"callback_uri"` 32 | SandboxPath string `json:"sandbox_path"` 33 | AgentIP string `json:"agent_ip"` 34 | AgentPort int32 `json:"agent_port"` 35 | ForcePullImage bool `json:"force_pull_image"` 36 | Privileged bool `json:"privileged"` 37 | FetchURIs []eremetic.URI `json:"fetch"` 38 | } 39 | 40 | // TaskV1FromTask is needed for Go versions < 1.8 41 | // In go 1.8, TaskV1(Task) would work instead 42 | func TaskV1FromTask(task *eremetic.Task) TaskV1 { 43 | return TaskV1{ 44 | TaskCPUs: task.TaskCPUs, 45 | TaskMem: task.TaskMem, 46 | Command: task.Command, 47 | Args: task.Args, 48 | User: task.User, 49 | Environment: task.Environment, 50 | MaskedEnvironment: task.MaskedEnvironment, 51 | Labels: task.Labels, 52 | Image: task.Image, 53 | Volumes: task.Volumes, 54 | VolumesFrom: task.VolumesFrom, 55 | Ports: task.Ports, 56 | Status: task.Status, 57 | ID: task.ID, 58 | Name: task.Name, 59 | Network: task.Network, 60 | DNS: task.DNS, 61 | FrameworkID: task.FrameworkID, 62 | AgentID: task.AgentID, 63 | AgentConstraints: task.AgentConstraints, 64 | Hostname: task.Hostname, 65 | Retry: task.Retry, 66 | CallbackURI: task.CallbackURI, 67 | SandboxPath: task.SandboxPath, 68 | AgentIP: task.AgentIP, 69 | AgentPort: task.AgentPort, 70 | ForcePullImage: task.ForcePullImage, 71 | Privileged: task.Privileged, 72 | FetchURIs: task.FetchURIs, 73 | } 74 | } 75 | 76 | // TaskFromV1 is needed for Go versions < 1.8 77 | // In go 1.8, Task(TaskV1) would work instead 78 | func TaskFromV1(task *TaskV1) eremetic.Task { 79 | return eremetic.Task{ 80 | TaskCPUs: task.TaskCPUs, 81 | TaskMem: task.TaskMem, 82 | Command: task.Command, 83 | Args: task.Args, 84 | User: task.User, 85 | Environment: task.Environment, 86 | MaskedEnvironment: task.MaskedEnvironment, 87 | Labels: task.Labels, 88 | Image: task.Image, 89 | Volumes: task.Volumes, 90 | VolumesFrom: task.VolumesFrom, 91 | Ports: task.Ports, 92 | Status: task.Status, 93 | ID: task.ID, 94 | Name: task.Name, 95 | Network: task.Network, 96 | DNS: task.DNS, 97 | FrameworkID: task.FrameworkID, 98 | AgentID: task.AgentID, 99 | AgentConstraints: task.AgentConstraints, 100 | Hostname: task.Hostname, 101 | Retry: task.Retry, 102 | CallbackURI: task.CallbackURI, 103 | SandboxPath: task.SandboxPath, 104 | AgentIP: task.AgentIP, 105 | AgentPort: task.AgentPort, 106 | ForcePullImage: task.ForcePullImage, 107 | Privileged: task.Privileged, 108 | FetchURIs: task.FetchURIs, 109 | } 110 | } 111 | 112 | // RequestV1 represents the V1 json-structure of a job request 113 | type RequestV1 struct { 114 | TaskCPUs float64 `json:"cpu"` 115 | TaskMem float64 `json:"mem"` 116 | DockerImage string `json:"image"` 117 | Command string `json:"command"` 118 | Args []string `json:"args"` 119 | Volumes []eremetic.Volume `json:"volumes"` 120 | VolumesFrom []string `json:"volumes_from"` 121 | Ports []eremetic.Port `json:"ports"` 122 | Name string `json:"name"` 123 | Network string `json:"network"` 124 | DNS string `json:"dns"` 125 | Environment map[string]string `json:"env"` 126 | MaskedEnvironment map[string]string `json:"masked_env"` 127 | Labels map[string]string `json:"labels"` 128 | AgentConstraints []eremetic.AgentConstraint `json:"agent_constraints"` 129 | CallbackURI string `json:"callback_uri"` 130 | Fetch []eremetic.URI `json:"fetch"` 131 | ForcePullImage bool `json:"force_pull_image"` 132 | Privileged bool `json:"privileged"` 133 | } 134 | 135 | // RequestFromV1 is needed for Go versions < 1.8 136 | // In go 1.8, Request(RequestV1) would work instead 137 | func RequestFromV1(req RequestV1) eremetic.Request { 138 | return eremetic.Request{ 139 | TaskCPUs: req.TaskCPUs, 140 | TaskMem: req.TaskMem, 141 | DockerImage: req.DockerImage, 142 | Command: req.Command, 143 | Args: req.Args, 144 | Volumes: req.Volumes, 145 | VolumesFrom: req.VolumesFrom, 146 | Ports: req.Ports, 147 | Name: req.Name, 148 | Network: req.Network, 149 | DNS: req.DNS, 150 | Environment: req.Environment, 151 | MaskedEnvironment: req.MaskedEnvironment, 152 | Labels: req.Labels, 153 | AgentConstraints: req.AgentConstraints, 154 | CallbackURI: req.CallbackURI, 155 | URIs: []string{}, 156 | Fetch: req.Fetch, 157 | ForcePullImage: req.ForcePullImage, 158 | Privileged: req.Privileged, 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /server/templates/task.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Task {{.Name}} | Eremetic 14 | 15 | 16 | 28 |
29 | 33 |

34 | {{.Name}} 35 |

36 |
37 |
38 |
39 |

40 | 41 |
Container
42 |

43 |
44 |
45 | 46 |
47 | {{.ContainerImage}} 48 |
49 |
50 |
51 | 52 |
53 | {{.CommandUser}} 54 |
55 |
56 |
57 | 58 |
59 | {{.Command}} 60 |
61 |
62 |
63 |
64 |
65 |

66 | 67 |
68 | Task Info 69 | {{if not .Terminated}} 70 | 71 | 72 | 73 | {{end}} 74 |
75 |

76 |
77 | {{if .FrameworkID}} 78 |
79 | 80 |
81 | {{.FrameworkID}} 82 |
83 |
84 | {{end}} 85 | {{if .Hostname}} 86 |
87 | 88 |
89 | {{.Hostname}} 90 |
91 |
92 | {{end}} 93 | {{if .AgentID}} 94 |
95 | 96 |
97 | {{.AgentID}} 98 |
99 |
100 | {{end}} 101 |
102 | 103 |
104 | {{.CPU}} 105 |
106 |
107 |
108 | 109 |
110 | {{.Memory}} 111 |
112 |
113 | {{if .AgentConstraints}} 114 |
115 | 116 |
117 | Agent constraints: 118 | {{range $index, $constraint := .AgentConstraints}} 119 |
{{$constraint.AttributeName}}={{$constraint.AttributeValue}} 120 | {{end}} 121 |
122 |
123 | {{end}} 124 |
125 |
126 |
127 |

128 | 129 |
Status
130 |

131 |
132 | {{range .Status}} 133 |
134 |
135 | {{.Status}} 136 |
137 |
138 | {{.Time | FormatTime}} 139 |
140 |
141 | {{end}} 142 |
143 |
144 |
145 |
146 |
147 |

STDOUT

148 |
149 |
150 | no content 151 |
152 |
153 |
154 |
155 |

STDERR

156 |
157 | no content 158 |
159 |
160 |
161 | 162 | 163 | -------------------------------------------------------------------------------- /mesos/task.go: -------------------------------------------------------------------------------- 1 | package mesos 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gogo/protobuf/proto" 7 | 8 | "github.com/mesos/mesos-go/api/v0/mesosproto" 9 | "github.com/mesos/mesos-go/api/v0/mesosutil" 10 | 11 | "github.com/eremetic-framework/eremetic" 12 | ) 13 | 14 | func createTaskInfo(task eremetic.Task, offer *mesosproto.Offer) (eremetic.Task, *mesosproto.TaskInfo) { 15 | task.FrameworkID = *offer.FrameworkId.Value 16 | task.AgentID = *offer.SlaveId.Value 17 | task.Hostname = *offer.Hostname 18 | task.AgentIP = offer.GetUrl().GetAddress().GetIp() 19 | task.AgentPort = offer.GetUrl().GetAddress().GetPort() 20 | 21 | network := buildNetwork(task) 22 | dockerCliParameters := buildDockerCliParameters(task) 23 | portMapping, portResources := buildPorts(task, network, offer) 24 | env := buildEnvironment(task, portMapping) 25 | 26 | taskInfo := &mesosproto.TaskInfo{ 27 | TaskId: &mesosproto.TaskID{Value: proto.String(task.ID)}, 28 | SlaveId: offer.SlaveId, 29 | Name: proto.String(task.Name), 30 | Command: buildCommandInfo(task, env), 31 | Container: &mesosproto.ContainerInfo{ 32 | Type: mesosproto.ContainerInfo_DOCKER.Enum(), 33 | Docker: &mesosproto.ContainerInfo_DockerInfo{ 34 | Image: proto.String(task.Image), 35 | ForcePullImage: proto.Bool(task.ForcePullImage), 36 | Privileged: proto.Bool(task.Privileged), 37 | Network: network, 38 | PortMappings: portMapping, 39 | Parameters: dockerCliParameters, 40 | }, 41 | Volumes: buildVolumes(task), 42 | }, 43 | Labels: buildLabels(task), 44 | Resources: []*mesosproto.Resource{ 45 | mesosutil.NewScalarResource("cpus", task.TaskCPUs), 46 | mesosutil.NewScalarResource("mem", task.TaskMem), 47 | mesosutil.NewRangesResource("ports", portResources), 48 | }, 49 | } 50 | return task, taskInfo 51 | } 52 | 53 | func buildDockerCliParameters(task eremetic.Task) []*mesosproto.Parameter { 54 | //To be able to move away from docker CLI in future, parameters aren't fully exposed to the API 55 | params := make(map[string]string) 56 | if task.DNS != "" { 57 | params["dns"] = task.DNS 58 | } 59 | var parameters []*mesosproto.Parameter 60 | for k, v := range params { 61 | parameters = append(parameters, &mesosproto.Parameter{ 62 | Key: proto.String(k), 63 | Value: proto.String(v), 64 | }) 65 | } 66 | if len(task.VolumesFrom) > 0 { 67 | for _, containerName := range task.VolumesFrom { 68 | if containerName == "" { 69 | continue 70 | } 71 | parameters = append(parameters, &mesosproto.Parameter{ 72 | Key: proto.String("volumes-from"), 73 | Value: proto.String(containerName), 74 | }) 75 | } 76 | } 77 | return parameters 78 | } 79 | 80 | func buildNetwork(task eremetic.Task) *mesosproto.ContainerInfo_DockerInfo_Network { 81 | if task.Network == "" { 82 | return mesosproto.ContainerInfo_DockerInfo_BRIDGE.Enum() 83 | } 84 | return mesosproto.ContainerInfo_DockerInfo_Network(mesosproto.ContainerInfo_DockerInfo_Network_value[task.Network]).Enum() 85 | } 86 | 87 | func buildEnvironment(task eremetic.Task, portMappings []*mesosproto.ContainerInfo_DockerInfo_PortMapping) *mesosproto.Environment { 88 | var environment []*mesosproto.Environment_Variable 89 | for k, v := range task.Environment { 90 | environment = append(environment, &mesosproto.Environment_Variable{ 91 | Name: proto.String(k), 92 | Value: proto.String(v), 93 | }) 94 | } 95 | for k, v := range task.MaskedEnvironment { 96 | environment = append(environment, &mesosproto.Environment_Variable{ 97 | Name: proto.String(k), 98 | Value: proto.String(v), 99 | }) 100 | } 101 | for i, m := range portMappings { 102 | environment = append(environment, &mesosproto.Environment_Variable{ 103 | Name: proto.String(fmt.Sprintf("PORT%d", i)), 104 | Value: proto.String(fmt.Sprintf("%d", *m.HostPort)), 105 | }) 106 | } 107 | if len(portMappings) > 0 { 108 | environment = append(environment, &mesosproto.Environment_Variable{ 109 | Name: proto.String("PORT"), 110 | Value: proto.String(fmt.Sprintf("%d", *portMappings[0].HostPort)), 111 | }) 112 | } 113 | 114 | environment = append(environment, &mesosproto.Environment_Variable{ 115 | Name: proto.String("MESOS_TASK_ID"), 116 | Value: proto.String(task.ID), 117 | }) 118 | 119 | return &mesosproto.Environment{ 120 | Variables: environment, 121 | } 122 | } 123 | 124 | func buildLabels(task eremetic.Task) *mesosproto.Labels { 125 | var labels *mesosproto.Labels 126 | var labelsSlice []*mesosproto.Label 127 | for k, v := range task.Labels { 128 | labelsSlice = append(labelsSlice, &mesosproto.Label{ 129 | Key: proto.String(k), 130 | Value: proto.String(v), 131 | }) 132 | } 133 | if len(labelsSlice) > 0 { 134 | labels = &mesosproto.Labels{ 135 | Labels: labelsSlice, 136 | } 137 | } 138 | return labels 139 | } 140 | 141 | func buildVolumes(task eremetic.Task) []*mesosproto.Volume { 142 | var volumes []*mesosproto.Volume 143 | for _, v := range task.Volumes { 144 | volumes = append(volumes, &mesosproto.Volume{ 145 | Mode: mesosproto.Volume_RW.Enum(), 146 | ContainerPath: proto.String(v.ContainerPath), 147 | HostPath: proto.String(v.HostPath), 148 | }) 149 | } 150 | 151 | return volumes 152 | } 153 | 154 | func buildPorts(task eremetic.Task, network *mesosproto.ContainerInfo_DockerInfo_Network, offer *mesosproto.Offer) ([]*mesosproto.ContainerInfo_DockerInfo_PortMapping, []*mesosproto.Value_Range) { 155 | var resources []*mesosproto.Value_Range 156 | var mappings []*mesosproto.ContainerInfo_DockerInfo_PortMapping 157 | 158 | if len(task.Ports) == 0 || *network == mesosproto.ContainerInfo_DockerInfo_HOST { 159 | return mappings, resources 160 | } 161 | 162 | leftToAssign := len(task.Ports) 163 | 164 | for _, rsrc := range offer.Resources { 165 | if *rsrc.Name != "ports" { 166 | continue 167 | } 168 | 169 | for _, rng := range rsrc.Ranges.Range { 170 | if leftToAssign == 0 { 171 | break 172 | } 173 | 174 | start, end := *rng.Begin, *rng.Begin 175 | 176 | for hport := int(*rng.Begin); hport <= int(*rng.End); hport++ { 177 | if leftToAssign == 0 { 178 | break 179 | } 180 | 181 | leftToAssign-- 182 | 183 | tport := &task.Ports[leftToAssign] 184 | tport.HostPort = uint32(hport) 185 | 186 | if tport.ContainerPort == 0 { 187 | tport.ContainerPort = tport.HostPort 188 | } 189 | 190 | end = uint64(hport + 1) 191 | 192 | mappings = append(mappings, &mesosproto.ContainerInfo_DockerInfo_PortMapping{ 193 | ContainerPort: proto.Uint32(tport.ContainerPort), 194 | HostPort: proto.Uint32(tport.HostPort), 195 | Protocol: proto.String(tport.Protocol), 196 | }) 197 | } 198 | 199 | if start != end { 200 | resources = append(resources, mesosutil.NewValueRange(start, end)) 201 | } 202 | } 203 | } 204 | 205 | return mappings, resources 206 | } 207 | 208 | func buildURIs(task eremetic.Task) []*mesosproto.CommandInfo_URI { 209 | var uris []*mesosproto.CommandInfo_URI 210 | for _, v := range task.FetchURIs { 211 | uris = append(uris, &mesosproto.CommandInfo_URI{ 212 | Value: proto.String(v.URI), 213 | Extract: proto.Bool(v.Extract), 214 | Executable: proto.Bool(v.Executable), 215 | Cache: proto.Bool(v.Cache), 216 | }) 217 | } 218 | 219 | return uris 220 | } 221 | 222 | func buildCommandInfo(task eremetic.Task, env *mesosproto.Environment) *mesosproto.CommandInfo { 223 | commandInfo := &mesosproto.CommandInfo{ 224 | User: proto.String(task.User), 225 | Environment: env, 226 | Uris: buildURIs(task), 227 | } 228 | 229 | if task.Command != "" { 230 | commandInfo.Shell = proto.Bool(true) 231 | commandInfo.Value = &task.Command 232 | } else { 233 | commandInfo.Shell = proto.Bool(false) 234 | commandInfo.Arguments = task.Args 235 | } 236 | 237 | return commandInfo 238 | } 239 | -------------------------------------------------------------------------------- /server/helpers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/url" 13 | "reflect" 14 | "strings" 15 | 16 | "github.com/sirupsen/logrus" 17 | 18 | "github.com/eremetic-framework/eremetic" 19 | "github.com/eremetic-framework/eremetic/api" 20 | "github.com/eremetic-framework/eremetic/config" 21 | "github.com/eremetic-framework/eremetic/server/assets" 22 | "github.com/eremetic-framework/eremetic/version" 23 | ) 24 | 25 | // getFile handles the actual fetching of file from the agent. 26 | func getFile(file string, task eremetic.Task) (int, io.ReadCloser) { 27 | if task.SandboxPath == "" { 28 | return http.StatusNoContent, nil 29 | } 30 | 31 | url := fmt.Sprintf( 32 | "http://%s:%d/files/download?path=%s/%s", 33 | task.AgentIP, 34 | task.AgentPort, 35 | task.SandboxPath, 36 | file, 37 | ) 38 | 39 | logrus.WithField("url", url).Debug("Fetching file from sandbox") 40 | 41 | response, err := http.Get(url) 42 | 43 | if err != nil { 44 | logrus.WithError(err).Errorf("Unable to fetch %s from agent %s.", file, task.AgentID) 45 | return http.StatusInternalServerError, ioutil.NopCloser(strings.NewReader("Unable to fetch upstream file.")) 46 | } 47 | 48 | return http.StatusOK, response.Body 49 | } 50 | 51 | func handleError(err error, w http.ResponseWriter, message string) { 52 | if err == nil { 53 | return 54 | } 55 | 56 | errorMessage := errorDocument{ 57 | err.Error(), 58 | message, 59 | } 60 | 61 | if err = writeJSON(422, errorMessage, w); err != nil { 62 | logrus.WithError(err).WithField("message", message).Panic("Unable to respond") 63 | } 64 | } 65 | 66 | func writeJSON(status int, data interface{}, w http.ResponseWriter) error { 67 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 68 | w.WriteHeader(status) 69 | return json.NewEncoder(w).Encode(data) 70 | } 71 | 72 | func renderHTML(w http.ResponseWriter, r *http.Request, task eremetic.Task, taskID string, conf *config.Config) { 73 | var templateFile string 74 | 75 | data := make(map[string]interface{}) 76 | funcMap := template.FuncMap{ 77 | "ToLower": strings.ToLower, 78 | "FormatTime": FormatTime, 79 | } 80 | 81 | if reflect.DeepEqual(task, (eremetic.Task{})) { 82 | notFound(w, r, conf) 83 | return 84 | } 85 | 86 | templateFile = "task.html" 87 | data = makeMap(task) 88 | data["Version"] = version.Version 89 | data["URLPrefix"] = conf.URLPrefix 90 | 91 | source, _ := assets.Asset(fmt.Sprintf("templates/%s", templateFile)) 92 | tpl, err := template.New(templateFile).Funcs(funcMap).Parse(string(source)) 93 | 94 | if err != nil { 95 | http.Error(w, err.Error(), http.StatusInternalServerError) 96 | logrus.WithError(err).WithField("template", templateFile).Error("Unable to load template") 97 | return 98 | } 99 | 100 | err = tpl.Execute(w, data) 101 | if err != nil { 102 | logrus.WithError(err).WithField("template", templateFile).Error("Unable to execute template") 103 | } 104 | } 105 | 106 | func notFound(w http.ResponseWriter, r *http.Request, conf *config.Config) { 107 | w.WriteHeader(http.StatusNotFound) 108 | 109 | data := make(map[string]interface{}) 110 | data["URLPrefix"] = conf.URLPrefix 111 | 112 | if strings.Contains(r.Header.Get("Accept"), "text/html") { 113 | src, _ := assets.Asset("templates/error_404.html") 114 | tpl, err := template.New("404").Parse(string(src)) 115 | if err != nil { 116 | logrus.WithError(err).WithField("template", "error_404.html").Error("Unable to load template") 117 | } 118 | err = tpl.Execute(w, data) 119 | if err != nil { 120 | logrus.WithError(err).WithField("template", "error_404.html").Error("Unable to execute template") 121 | } 122 | } 123 | 124 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 125 | json.NewEncoder(w).Encode(nil) 126 | } 127 | 128 | func makeMap(task eremetic.Task) map[string]interface{} { 129 | data := make(map[string]interface{}) 130 | 131 | data["TaskID"] = task.ID 132 | data["CommandEnv"] = task.Environment 133 | data["CommandUser"] = task.User 134 | data["Command"] = task.Command 135 | // TODO: Support more than docker? 136 | data["ContainerImage"] = task.Image 137 | data["FrameworkID"] = task.FrameworkID 138 | data["Hostname"] = task.Hostname 139 | data["Name"] = task.Name 140 | data["AgentID"] = task.AgentID 141 | data["AgentConstraints"] = task.AgentConstraints 142 | data["Status"] = task.Status 143 | data["CPU"] = fmt.Sprintf("%.2f", task.TaskCPUs) 144 | data["Memory"] = fmt.Sprintf("%.2f", task.TaskMem) 145 | data["Terminated"] = task.IsTerminated() 146 | 147 | return data 148 | } 149 | 150 | func absURL(r *http.Request, path string, conf *config.Config) string { 151 | scheme := r.Header.Get("X-Forwarded-Proto") 152 | if scheme == "" { 153 | scheme = "http" 154 | } 155 | 156 | if conf.URLPrefix != "" { 157 | path = fmt.Sprintf("%s%s", conf.URLPrefix, path) 158 | logrus.WithField("path", path).Debug("absurl was computed") 159 | } 160 | 161 | url := url.URL{ 162 | Scheme: scheme, 163 | Host: r.Host, 164 | Path: path, 165 | } 166 | return url.String() 167 | } 168 | 169 | func parseHTTPCredentials(credentials string) (string, string) { 170 | if credentials == "" { 171 | return "", "" 172 | } 173 | 174 | pair := strings.SplitN(credentials, ":", 2) 175 | if len(pair) != 2 { 176 | logrus.WithField("http_credentials", credentials).Error("using 'username:password' format for http_credentials") 177 | return "", "" 178 | } 179 | 180 | return pair[0], pair[1] 181 | } 182 | 183 | func checkAuth(r *http.Request, user string, password string) error { 184 | s := strings.SplitN(r.Header.Get("Authorization"), " ", 2) 185 | badErr := errors.New("bad authorization") 186 | 187 | if len(s) != 2 || s[0] != "Basic" { 188 | return badErr 189 | } 190 | 191 | b, err := base64.StdEncoding.DecodeString(s[1]) 192 | if err != nil { 193 | return err 194 | } 195 | 196 | pair := strings.SplitN(string(b), ":", 2) 197 | if len(pair) != 2 { 198 | return badErr 199 | } 200 | if pair[0] != user || pair[1] != password { 201 | return badErr 202 | } 203 | return nil 204 | } 205 | 206 | func requireAuth(w http.ResponseWriter, r *http.Request) { 207 | if strings.Contains(r.Header.Get("Accept"), "text/html") { 208 | src, _ := assets.Asset("templates/error_401.html") 209 | tpl, err := template.New("401").Parse(string(src)) 210 | if err == nil { 211 | w.Header().Set("WWW-Authenticate", `basic realm="Eremetic"`) 212 | w.WriteHeader(http.StatusUnauthorized) 213 | tpl.Execute(w, nil) 214 | return 215 | } 216 | logrus.WithError(err).WithField("template", "error_401.html").Error("Unable to load template") 217 | } 218 | 219 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 220 | w.WriteHeader(http.StatusUnauthorized) 221 | json.NewEncoder(w).Encode(nil) 222 | } 223 | 224 | func authWrap(fn http.Handler, username, password string) http.HandlerFunc { 225 | return func(w http.ResponseWriter, r *http.Request) { 226 | 227 | err := checkAuth(r, username, password) 228 | if err != nil { 229 | requireAuth(w, r) 230 | return 231 | } 232 | 233 | fn.ServeHTTP(w, r) 234 | } 235 | } 236 | 237 | func getTaskInfoV0(t eremetic.Task, conf *config.Config, id string, w http.ResponseWriter, r *http.Request) { 238 | if strings.Contains(r.Header.Get("Accept"), "text/html") { 239 | renderHTML(w, r, t, id, conf) 240 | } else { 241 | task := api.TaskV0FromTask(&t) 242 | if reflect.DeepEqual(task, (api.TaskV0{})) { 243 | writeJSON(http.StatusNotFound, nil, w) 244 | return 245 | } 246 | writeJSON(http.StatusOK, task, w) 247 | } 248 | } 249 | 250 | func getTaskInfoV1(t eremetic.Task, conf *config.Config, id string, w http.ResponseWriter, r *http.Request) { 251 | task := api.TaskV1FromTask(&t) 252 | if reflect.DeepEqual(task, (api.TaskV1{})) { 253 | writeJSON(http.StatusNotFound, nil, w) 254 | return 255 | } 256 | writeJSON(http.StatusOK, task, w) 257 | } 258 | 259 | func deprecated(w http.ResponseWriter) { 260 | w.Header().Set("Warning", "299 - 'Deprecated API'") 261 | } 262 | -------------------------------------------------------------------------------- /boltdb/boltdb_test.go: -------------------------------------------------------------------------------- 1 | package boltdb 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | . "github.com/smartystreets/goconvey/convey" 12 | 13 | "github.com/eremetic-framework/eremetic" 14 | ) 15 | 16 | func TestBoltDatabase(t *testing.T) { 17 | var ( 18 | testDB string 19 | db *TaskDB 20 | ) 21 | 22 | setup := func() error { 23 | dir, _ := ioutil.TempDir("", "eremetic") 24 | testDB = fmt.Sprintf("%s/test.db", dir) 25 | adb, err := newCustomTaskDB(defaultConnector{}, testDB) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | db = adb 31 | 32 | return nil 33 | } 34 | 35 | teardown := func() { 36 | os.Remove(testDB) 37 | } 38 | 39 | status := []eremetic.Status{ 40 | eremetic.Status{ 41 | Status: eremetic.TaskRunning, 42 | Time: time.Now().Unix(), 43 | }, 44 | } 45 | 46 | Convey("NewDB", t, func() { 47 | Convey("With an absolute path", func() { 48 | setup() 49 | defer teardown() 50 | 51 | So(db.conn.Path(), ShouldNotBeEmpty) 52 | So(filepath.IsAbs(db.conn.Path()), ShouldBeTrue) 53 | }) 54 | }) 55 | 56 | Convey("createBoltDriver", t, func() { 57 | Convey("Error", func() { 58 | setup() 59 | defer teardown() 60 | defer db.Close() 61 | 62 | connector := new(mockConnector) 63 | _, err := newCustomTaskDB(connector, "") 64 | 65 | So(err, ShouldNotBeNil) 66 | So(err.Error(), ShouldEqual, "missing boltdb database location") 67 | }) 68 | }) 69 | 70 | Convey("Close", t, func() { 71 | setup() 72 | defer teardown() 73 | db.Close() 74 | 75 | So(db.conn.Path(), ShouldBeEmpty) 76 | }) 77 | 78 | Convey("Clean", t, func() { 79 | setup() 80 | defer teardown() 81 | defer db.Close() 82 | 83 | db.PutTask(&eremetic.Task{ID: "1234"}) 84 | task, _ := db.ReadTask("1234") 85 | So(task, ShouldNotEqual, eremetic.Task{}) 86 | So(task.ID, ShouldNotBeEmpty) 87 | 88 | db.Clean() 89 | 90 | task, _ = db.ReadTask("1234") 91 | So(task, ShouldBeZeroValue) 92 | }) 93 | 94 | Convey("Put and Read Task", t, func() { 95 | setup() 96 | defer teardown() 97 | defer db.Close() 98 | 99 | var maskedEnv = make(map[string]string) 100 | maskedEnv["foo"] = "bar" 101 | 102 | task1 := eremetic.Task{ID: "1234"} 103 | task2 := eremetic.Task{ 104 | ID: "12345", 105 | TaskCPUs: 2.5, 106 | TaskMem: 15.3, 107 | Name: "request Name", 108 | Status: status, 109 | FrameworkID: "1234", 110 | Command: "echo date", 111 | User: "root", 112 | Image: "busybox", 113 | MaskedEnvironment: maskedEnv, 114 | } 115 | 116 | db.PutTask(&task1) 117 | db.PutTask(&task2) 118 | 119 | t1, err := db.ReadTask(task1.ID) 120 | So(t1, ShouldResemble, task1) 121 | So(err, ShouldBeNil) 122 | t2, err := db.ReadTask(task2.ID) 123 | So(err, ShouldBeNil) 124 | So(t2.MaskedEnvironment["foo"], ShouldEqual, "*******") 125 | }) 126 | 127 | Convey("Read unmasked task", t, func() { 128 | setup() 129 | defer teardown() 130 | defer db.Close() 131 | 132 | var maskedEnv = make(map[string]string) 133 | maskedEnv["foo"] = "bar" 134 | 135 | task := eremetic.Task{ 136 | ID: "12345", 137 | TaskCPUs: 2.5, 138 | TaskMem: 15.3, 139 | Name: "request Name", 140 | Status: status, 141 | FrameworkID: "1234", 142 | Command: "echo date", 143 | User: "root", 144 | Image: "busybox", 145 | MaskedEnvironment: maskedEnv, 146 | } 147 | db.PutTask(&task) 148 | 149 | t, err := db.ReadUnmaskedTask(task.ID) 150 | So(t, ShouldResemble, task) 151 | So(err, ShouldBeNil) 152 | So(t.MaskedEnvironment, ShouldContainKey, "foo") 153 | So(t.MaskedEnvironment["foo"], ShouldEqual, "bar") 154 | 155 | }) 156 | 157 | Convey("List non-terminal tasks", t, func() { 158 | setup() 159 | defer teardown() 160 | defer db.Close() 161 | 162 | db.Clean() 163 | 164 | // A terminated task 165 | db.PutTask(&eremetic.Task{ 166 | ID: "1234", 167 | Status: []eremetic.Status{ 168 | eremetic.Status{ 169 | Status: eremetic.TaskStaging, 170 | Time: time.Now().Unix(), 171 | }, 172 | eremetic.Status{ 173 | Status: eremetic.TaskRunning, 174 | Time: time.Now().Unix(), 175 | }, 176 | eremetic.Status{ 177 | Status: eremetic.TaskFinished, 178 | Time: time.Now().Unix(), 179 | }, 180 | }, 181 | }) 182 | 183 | // A running task 184 | db.PutTask(&eremetic.Task{ 185 | ID: "2345", 186 | Status: []eremetic.Status{ 187 | eremetic.Status{ 188 | Status: eremetic.TaskStaging, 189 | Time: time.Now().Unix(), 190 | }, 191 | eremetic.Status{ 192 | Status: eremetic.TaskRunning, 193 | Time: time.Now().Unix(), 194 | }, 195 | }, 196 | }) 197 | 198 | tasks, err := db.ListTasks(&eremetic.TaskFilter{ 199 | State: eremetic.DefaultTaskFilterState, 200 | }) 201 | So(err, ShouldBeNil) 202 | So(tasks, ShouldHaveLength, 1) 203 | task := tasks[0] 204 | So(task.ID, ShouldEqual, "2345") 205 | }) 206 | 207 | Convey("ListTasks", t, func() { 208 | setup() 209 | defer teardown() 210 | defer db.Close() 211 | 212 | db.Clean() 213 | 214 | // A terminated task 215 | db.PutTask(&eremetic.Task{ 216 | ID: "1234", 217 | Status: []eremetic.Status{ 218 | eremetic.Status{ 219 | Status: eremetic.TaskStaging, 220 | Time: time.Now().Unix(), 221 | }, 222 | eremetic.Status{ 223 | Status: eremetic.TaskRunning, 224 | Time: time.Now().Unix(), 225 | }, 226 | eremetic.Status{ 227 | Status: eremetic.TaskFinished, 228 | Time: time.Now().Unix(), 229 | }, 230 | }, 231 | }) 232 | 233 | // A running task 234 | db.PutTask(&eremetic.Task{ 235 | ID: "2345", 236 | Status: []eremetic.Status{ 237 | eremetic.Status{ 238 | Status: eremetic.TaskStaging, 239 | Time: time.Now().Unix(), 240 | }, 241 | eremetic.Status{ 242 | Status: eremetic.TaskRunning, 243 | Time: time.Now().Unix(), 244 | }, 245 | }, 246 | }) 247 | Convey("List default non terminated tasks", func() { 248 | tasks, err := db.ListTasks(&eremetic.TaskFilter{ 249 | State: eremetic.DefaultTaskFilterState, 250 | }) 251 | So(err, ShouldBeNil) 252 | So(tasks, ShouldHaveLength, 1) 253 | task := tasks[0] 254 | So(task.ID, ShouldEqual, "2345") 255 | }) 256 | 257 | Convey("List terminated tasks", func() { 258 | tasks, err := db.ListTasks(&eremetic.TaskFilter{ 259 | State: eremetic.TerminatedState, 260 | }) 261 | So(err, ShouldBeNil) 262 | So(tasks, ShouldHaveLength, 1) 263 | task := tasks[0] 264 | So(task.ID, ShouldEqual, "1234") 265 | }) 266 | }) 267 | 268 | Convey("DeleteTask", t, func() { 269 | Convey("Success", func() { 270 | setup() 271 | defer teardown() 272 | defer db.Close() 273 | db.Clean() 274 | 275 | var maskedEnv = make(map[string]string) 276 | maskedEnv["foo"] = "bar" 277 | 278 | task1 := eremetic.Task{ID: "1234"} 279 | db.PutTask(&task1) 280 | t1, err := db.ReadUnmaskedTask(task1.ID) 281 | So(t1, ShouldResemble, task1) 282 | So(err, ShouldBeNil) 283 | 284 | err = db.DeleteTask(task1.ID) 285 | So(err, ShouldBeNil) 286 | }) 287 | }) 288 | 289 | Convey("List non-terminal tasks no running task", t, func() { 290 | setup() 291 | defer teardown() 292 | defer db.Close() 293 | 294 | db.Clean() 295 | db.PutTask(&eremetic.Task{ 296 | ID: "1234", 297 | Status: []eremetic.Status{ 298 | eremetic.Status{ 299 | Status: eremetic.TaskStaging, 300 | Time: time.Now().Unix(), 301 | }, 302 | eremetic.Status{ 303 | Status: eremetic.TaskRunning, 304 | Time: time.Now().Unix(), 305 | }, 306 | eremetic.Status{ 307 | Status: eremetic.TaskFinished, 308 | Time: time.Now().Unix(), 309 | }, 310 | }, 311 | }) 312 | tasks, err := db.ListTasks(&eremetic.TaskFilter{ 313 | State: eremetic.DefaultTaskFilterState, 314 | }) 315 | So(err, ShouldBeNil) 316 | So(tasks, ShouldBeEmpty) 317 | So(tasks, ShouldNotBeNil) 318 | }) 319 | } 320 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | 13 | . "github.com/smartystreets/goconvey/convey" 14 | 15 | "github.com/eremetic-framework/eremetic" 16 | "github.com/eremetic-framework/eremetic/config" 17 | "github.com/eremetic-framework/eremetic/mock" 18 | ) 19 | 20 | func TestServer(t *testing.T) { 21 | Convey("Server", t, func() { 22 | Convey("AddTask", func() { 23 | Convey("Simple", func() { 24 | sched := mock.Scheduler{ 25 | ScheduleTaskFn: func(req eremetic.Request) (string, error) { 26 | return "task_id", nil 27 | }, 28 | } 29 | 30 | db := mock.TaskDB{} 31 | cfg := config.Config{} 32 | 33 | srv := NewRouter(&sched, &cfg, &db) 34 | 35 | var body bytes.Buffer 36 | body.WriteString(`{}`) 37 | 38 | rec := httptest.NewRecorder() 39 | r, _ := http.NewRequest("POST", "http://example.com/task", &body) 40 | 41 | srv.ServeHTTP(rec, r) 42 | 43 | So(rec.Code, ShouldEqual, http.StatusAccepted) 44 | }) 45 | Convey("QueueFull", func() { 46 | sched := mock.Scheduler{ 47 | ScheduleTaskFn: func(req eremetic.Request) (string, error) { 48 | return "", eremetic.ErrQueueFull 49 | }, 50 | } 51 | 52 | db := mock.TaskDB{} 53 | cfg := config.Config{} 54 | 55 | srv := NewRouter(&sched, &cfg, &db) 56 | 57 | var body bytes.Buffer 58 | body.WriteString(`{}`) 59 | 60 | rec := httptest.NewRecorder() 61 | r, _ := http.NewRequest("POST", "http://example.com/task", &body) 62 | 63 | srv.ServeHTTP(rec, r) 64 | 65 | So(rec.Code, ShouldEqual, http.StatusServiceUnavailable) 66 | }) 67 | Convey("UnknownError", func() { 68 | sched := mock.Scheduler{ 69 | ScheduleTaskFn: func(req eremetic.Request) (string, error) { 70 | return "", errors.New("unknown error") 71 | }, 72 | } 73 | 74 | db := mock.TaskDB{} 75 | cfg := config.Config{} 76 | 77 | srv := NewRouter(&sched, &cfg, &db) 78 | 79 | var body bytes.Buffer 80 | body.WriteString(`{}`) 81 | 82 | rec := httptest.NewRecorder() 83 | r, _ := http.NewRequest("POST", "http://example.com/task", &body) 84 | 85 | srv.ServeHTTP(rec, r) 86 | 87 | So(rec.Code, ShouldEqual, http.StatusInternalServerError) 88 | }) 89 | }) 90 | Convey("GetFromSandBox", func() { 91 | Convey("Simple", func() { 92 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | w.Write([]byte("OK")) 94 | })) 95 | defer ts.Close() 96 | 97 | agentURL, _ := url.Parse(ts.URL) 98 | host := strings.Split(agentURL.Host, ":") 99 | port, _ := strconv.ParseInt(host[1], 10, 64) 100 | 101 | sched := mock.Scheduler{} 102 | 103 | db := mock.TaskDB{ 104 | ReadTaskFn: func(id string) (eremetic.Task, error) { 105 | return eremetic.Task{ 106 | ID: id, 107 | AgentIP: host[0], 108 | AgentPort: int32(port), 109 | SandboxPath: "/tmp", 110 | }, nil 111 | }, 112 | } 113 | 114 | cfg := config.Config{} 115 | 116 | srv := NewRouter(&sched, &cfg, &db) 117 | 118 | rec := httptest.NewRecorder() 119 | r, _ := http.NewRequest("GET", "http://example.com/task/test_id/stdout", nil) 120 | 121 | srv.ServeHTTP(rec, r) 122 | 123 | So(rec.Code, ShouldEqual, http.StatusOK) 124 | So(rec.Body.String(), ShouldEqual, "OK") 125 | }) 126 | Convey("MissingSandboxPath", func() { 127 | sched := mock.Scheduler{} 128 | 129 | db := mock.TaskDB{ 130 | ReadTaskFn: func(id string) (eremetic.Task, error) { 131 | return eremetic.Task{ 132 | ID: id, 133 | }, nil 134 | }, 135 | } 136 | 137 | cfg := config.Config{} 138 | 139 | srv := NewRouter(&sched, &cfg, &db) 140 | 141 | rec := httptest.NewRecorder() 142 | r, _ := http.NewRequest("GET", "http://example.com/task/test_id/stdout", nil) 143 | 144 | srv.ServeHTTP(rec, r) 145 | 146 | So(rec.Code, ShouldEqual, http.StatusNoContent) 147 | }) 148 | }) 149 | Convey("GetTaskInfo", func() { 150 | Convey("Simple", func() { 151 | sched := mock.Scheduler{} 152 | 153 | db := mock.TaskDB{ 154 | ReadTaskFn: func(id string) (eremetic.Task, error) { 155 | return eremetic.Task{ 156 | ID: id, 157 | }, nil 158 | }, 159 | } 160 | 161 | cfg := config.Config{} 162 | 163 | srv := NewRouter(&sched, &cfg, &db) 164 | 165 | rec := httptest.NewRecorder() 166 | r, _ := http.NewRequest("GET", "http://example.com/task/test_id", nil) 167 | 168 | srv.ServeHTTP(rec, r) 169 | 170 | So(rec.Code, ShouldEqual, http.StatusOK) 171 | }) 172 | Convey("TaskNotFound", func() { 173 | sched := mock.Scheduler{} 174 | 175 | db := mock.TaskDB{ 176 | ReadTaskFn: func(id string) (eremetic.Task, error) { 177 | return eremetic.Task{}, nil 178 | }, 179 | } 180 | 181 | cfg := config.Config{} 182 | 183 | srv := NewRouter(&sched, &cfg, &db) 184 | 185 | rec := httptest.NewRecorder() 186 | r, _ := http.NewRequest("GET", "http://example.com/task/unknown_id", nil) 187 | 188 | srv.ServeHTTP(rec, r) 189 | 190 | So(rec.Code, ShouldEqual, http.StatusNotFound) 191 | }) 192 | }) 193 | Convey("ListRunningTasks", func() { 194 | Convey("Simple", func() { 195 | sched := mock.Scheduler{} 196 | 197 | db := mock.TaskDB{ 198 | ListTasksFn: func(filter *eremetic.TaskFilter) ([]*eremetic.Task, error) { 199 | return []*eremetic.Task{}, nil 200 | }, 201 | } 202 | 203 | cfg := config.Config{} 204 | 205 | srv := NewRouter(&sched, &cfg, &db) 206 | 207 | rec := httptest.NewRecorder() 208 | r, _ := http.NewRequest("GET", "http://example.com/task", nil) 209 | 210 | srv.ServeHTTP(rec, r) 211 | 212 | So(rec.Code, ShouldEqual, http.StatusOK) 213 | }) 214 | }) 215 | Convey("Index", func() { 216 | Convey("Simple", func() { 217 | sched := mock.Scheduler{} 218 | 219 | db := mock.TaskDB{ 220 | ListNonTerminalTasksFn: func() ([]*eremetic.Task, error) { 221 | return []*eremetic.Task{}, nil 222 | }, 223 | } 224 | 225 | cfg := config.Config{} 226 | 227 | srv := NewRouter(&sched, &cfg, &db) 228 | 229 | rec := httptest.NewRecorder() 230 | r, _ := http.NewRequest("GET", "http://example.com/", nil) 231 | r.Header.Set("Accept", "text/html") 232 | 233 | srv.ServeHTTP(rec, r) 234 | 235 | So(rec.Code, ShouldEqual, http.StatusOK) 236 | }) 237 | Convey("DoesNotAcceptHTML", func() { 238 | sched := mock.Scheduler{} 239 | 240 | db := mock.TaskDB{ 241 | ListNonTerminalTasksFn: func() ([]*eremetic.Task, error) { 242 | return []*eremetic.Task{}, nil 243 | }, 244 | } 245 | 246 | cfg := config.Config{} 247 | 248 | srv := NewRouter(&sched, &cfg, &db) 249 | 250 | rec := httptest.NewRecorder() 251 | r, _ := http.NewRequest("GET", "http://example.com/", nil) 252 | 253 | srv.ServeHTTP(rec, r) 254 | 255 | So(rec.Code, ShouldEqual, http.StatusNoContent) 256 | }) 257 | }) 258 | Convey("Auth", func() { 259 | Convey("IndexUnauthorized", func() { 260 | sched := mock.Scheduler{} 261 | 262 | db := mock.TaskDB{ 263 | ListNonTerminalTasksFn: func() ([]*eremetic.Task, error) { 264 | return []*eremetic.Task{}, nil 265 | }, 266 | } 267 | 268 | cfg := config.Config{HTTPCredentials: "admin:admin"} 269 | 270 | srv := NewRouter(&sched, &cfg, &db) 271 | 272 | rec := httptest.NewRecorder() 273 | r, _ := http.NewRequest("GET", "http://example.com/", nil) 274 | r.Header.Set("Accept", "text/html") 275 | 276 | srv.ServeHTTP(rec, r) 277 | 278 | So(rec.Code, ShouldEqual, http.StatusUnauthorized) 279 | }) 280 | 281 | Convey("IndexOK", func() { 282 | sched := mock.Scheduler{} 283 | 284 | db := mock.TaskDB{ 285 | ListNonTerminalTasksFn: func() ([]*eremetic.Task, error) { 286 | return []*eremetic.Task{}, nil 287 | }, 288 | } 289 | 290 | cfg := config.Config{HTTPCredentials: "admin:admin"} 291 | 292 | srv := NewRouter(&sched, &cfg, &db) 293 | 294 | rec := httptest.NewRecorder() 295 | r, _ := http.NewRequest("GET", "http://example.com/", nil) 296 | r.Header.Set("Accept", "text/html") 297 | r.SetBasicAuth("admin", "admin") 298 | 299 | srv.ServeHTTP(rec, r) 300 | 301 | So(rec.Code, ShouldEqual, http.StatusOK) 302 | }) 303 | }) 304 | }) 305 | } 306 | -------------------------------------------------------------------------------- /mock/mesos_scheduler.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/mesos/mesos-go/api/v0/mesosproto" 5 | "github.com/mesos/mesos-go/api/v0/scheduler" 6 | ) 7 | 8 | // MesosScheduler implements a mocked mesos scheduler iterface for testing 9 | type MesosScheduler struct { 10 | AbortFn func() (mesosproto.Status, error) 11 | AbortFnInvoked bool 12 | AcceptOffersFn func([]*mesosproto.OfferID, []*mesosproto.Offer_Operation, *mesosproto.Filters) (mesosproto.Status, error) 13 | AcceptOffersFnInvoked bool 14 | DeclineOfferFn func(*mesosproto.OfferID, *mesosproto.Filters) (mesosproto.Status, error) 15 | DeclineOfferFnInvoked bool 16 | JoinFn func() (mesosproto.Status, error) 17 | JoinFnInvoked bool 18 | KillTaskFn func(*mesosproto.TaskID) (mesosproto.Status, error) 19 | KillTaskFnInvoked bool 20 | ReconcileTasksFn func([]*mesosproto.TaskStatus) (mesosproto.Status, error) 21 | ReconcileTasksFnInvoked bool 22 | RequestResourcesFn func([]*mesosproto.Request) (mesosproto.Status, error) 23 | RequestResourcesFnInvoked bool 24 | ReviveOffersFn func() (mesosproto.Status, error) 25 | ReviveOffersFnInvoked bool 26 | RunFn func() (mesosproto.Status, error) 27 | RunFnInvoked bool 28 | StartFn func() (mesosproto.Status, error) 29 | StartFnInvoked bool 30 | StopFn func(bool) (mesosproto.Status, error) 31 | StopFnInvoked bool 32 | SendFrameworkMessageFn func(*mesosproto.ExecutorID, *mesosproto.SlaveID, string) (mesosproto.Status, error) 33 | SendFrameworkMessageFnInvoked bool 34 | LaunchTasksFn func([]*mesosproto.OfferID, []*mesosproto.TaskInfo, *mesosproto.Filters) (mesosproto.Status, error) 35 | LaunchTasksFnInvoked bool 36 | RegisteredFn func(scheduler.SchedulerDriver, *mesosproto.FrameworkID, *mesosproto.MasterInfo) 37 | RegisteredFnInvoked bool 38 | ReregisteredFn func(scheduler.SchedulerDriver, *mesosproto.MasterInfo) 39 | ReregisteredFnInvoked bool 40 | DisconnectedFn func(scheduler.SchedulerDriver) 41 | DisconnectedFnInvoked bool 42 | ResourceOffersFn func(scheduler.SchedulerDriver, []*mesosproto.Offer) 43 | ResourceOffersFnInvoked bool 44 | OfferRescindedFn func(scheduler.SchedulerDriver, *mesosproto.OfferID) 45 | OfferRescindedFnInvoked bool 46 | StatusUpdateFn func(scheduler.SchedulerDriver, *mesosproto.TaskStatus) 47 | StatusUpdateFnInvoked bool 48 | FrameworkMessageFn func(scheduler.SchedulerDriver, *mesosproto.ExecutorID, *mesosproto.SlaveID, string) 49 | FrameworkMessageFnInvoked bool 50 | SlaveLostFn func(scheduler.SchedulerDriver, *mesosproto.SlaveID) 51 | SlaveLostFnInvoked bool 52 | ExecutorLostFn func(scheduler.SchedulerDriver, *mesosproto.ExecutorID, *mesosproto.SlaveID, int) 53 | ExecutorLostFnInvoked bool 54 | ErrorFn func(scheduler.SchedulerDriver, string) 55 | ErrorFnInvoked bool 56 | } 57 | 58 | // NewMesosScheduler returns a new mocked mesos scheduler 59 | func NewMesosScheduler() *MesosScheduler { 60 | return &MesosScheduler{} 61 | } 62 | 63 | // Abort mocks the abort functionality 64 | func (m *MesosScheduler) Abort() (stat mesosproto.Status, err error) { 65 | m.AbortFnInvoked = true 66 | return m.AbortFn() 67 | } 68 | 69 | // AcceptOffers mocks the AcceptOffers functionality 70 | func (m *MesosScheduler) AcceptOffers(offerIds []*mesosproto.OfferID, operations []*mesosproto.Offer_Operation, filters *mesosproto.Filters) (mesosproto.Status, error) { 71 | m.AcceptOffersFnInvoked = true 72 | return m.AcceptOffersFn(offerIds, operations, filters) 73 | } 74 | 75 | // DeclineOffer mocks the DeclineOffer functionality 76 | func (m *MesosScheduler) DeclineOffer(offerID *mesosproto.OfferID, filters *mesosproto.Filters) (mesosproto.Status, error) { 77 | m.DeclineOfferFnInvoked = true 78 | return m.DeclineOfferFn(offerID, filters) 79 | } 80 | 81 | // Join mocks the Join functionality 82 | func (m *MesosScheduler) Join() (mesosproto.Status, error) { 83 | m.JoinFnInvoked = true 84 | return m.JoinFn() 85 | } 86 | 87 | // KillTask mocks the KillTask functionality 88 | func (m *MesosScheduler) KillTask(id *mesosproto.TaskID) (mesosproto.Status, error) { 89 | m.KillTaskFnInvoked = true 90 | return m.KillTaskFn(id) 91 | } 92 | 93 | // ReconcileTasks mocks the ReconcileTasks functionality 94 | func (m *MesosScheduler) ReconcileTasks(ts []*mesosproto.TaskStatus) (mesosproto.Status, error) { 95 | m.ReconcileTasksFnInvoked = true 96 | return m.ReconcileTasksFn(ts) 97 | } 98 | 99 | // RequestResources mocks the RequestResources functionality 100 | func (m *MesosScheduler) RequestResources(r []*mesosproto.Request) (mesosproto.Status, error) { 101 | m.RequestResourcesFnInvoked = true 102 | return m.RequestResourcesFn(r) 103 | } 104 | 105 | // ReviveOffers mocks the ReviveOffers functionality 106 | func (m *MesosScheduler) ReviveOffers() (mesosproto.Status, error) { 107 | m.ReviveOffersFnInvoked = true 108 | return m.ReviveOffersFn() 109 | } 110 | 111 | // Run mocks the Run functionality 112 | func (m *MesosScheduler) Run() (mesosproto.Status, error) { 113 | m.RunFnInvoked = true 114 | return m.RunFn() 115 | } 116 | 117 | // Start mocks the Start functionality 118 | func (m *MesosScheduler) Start() (mesosproto.Status, error) { 119 | m.StartFnInvoked = true 120 | return m.StartFn() 121 | } 122 | 123 | // Stop mocks the Stop functionality 124 | func (m *MesosScheduler) Stop(b bool) (mesosproto.Status, error) { 125 | m.StopFnInvoked = true 126 | return m.StopFn(b) 127 | } 128 | 129 | // SendFrameworkMessage mocks the SendFrameworkMessage functionality 130 | func (m *MesosScheduler) SendFrameworkMessage(eID *mesosproto.ExecutorID, sID *mesosproto.SlaveID, s string) (mesosproto.Status, error) { 131 | m.SendFrameworkMessageFnInvoked = true 132 | return m.SendFrameworkMessageFn(eID, sID, s) 133 | } 134 | 135 | // LaunchTasks mocks the LaunchTasks functionality 136 | func (m *MesosScheduler) LaunchTasks(o []*mesosproto.OfferID, t []*mesosproto.TaskInfo, f *mesosproto.Filters) (mesosproto.Status, error) { 137 | m.LaunchTasksFnInvoked = true 138 | return m.LaunchTasksFn(o, t, f) 139 | } 140 | 141 | // Registered mocks the Registered functionality 142 | func (m *MesosScheduler) Registered(s scheduler.SchedulerDriver, f *mesosproto.FrameworkID, minfo *mesosproto.MasterInfo) { 143 | m.RegisteredFnInvoked = true 144 | m.RegisteredFn(s, f, minfo) 145 | } 146 | 147 | // Reregistered mocks the Reregistered functionality 148 | func (m *MesosScheduler) Reregistered(s scheduler.SchedulerDriver, info *mesosproto.MasterInfo) { 149 | m.ReregisteredFnInvoked = true 150 | m.ReregisteredFn(s, info) 151 | } 152 | 153 | // Disconnected mocks the Disconnected functionality 154 | func (m *MesosScheduler) Disconnected(s scheduler.SchedulerDriver) { 155 | m.DisconnectedFnInvoked = true 156 | m.DisconnectedFn(s) 157 | } 158 | 159 | // ResourceOffers mocks the ResourceOffers functionality 160 | func (m *MesosScheduler) ResourceOffers(s scheduler.SchedulerDriver, o []*mesosproto.Offer) { 161 | m.ResourceOffersFnInvoked = true 162 | m.ResourceOffersFn(s, o) 163 | } 164 | 165 | // OfferRescinded mocks the OfferRescinded functionality 166 | func (m *MesosScheduler) OfferRescinded(s scheduler.SchedulerDriver, o *mesosproto.OfferID) { 167 | m.OfferRescindedFnInvoked = true 168 | m.OfferRescindedFn(s, o) 169 | } 170 | 171 | // StatusUpdate mocks the StatusUpdate functionality 172 | func (m *MesosScheduler) StatusUpdate(s scheduler.SchedulerDriver, ts *mesosproto.TaskStatus) { 173 | m.StatusUpdateFnInvoked = true 174 | m.StatusUpdateFn(s, ts) 175 | } 176 | 177 | // FrameworkMessage mocks the FrameworkMessage functionality 178 | func (m *MesosScheduler) FrameworkMessage(sd scheduler.SchedulerDriver, eID *mesosproto.ExecutorID, sID *mesosproto.SlaveID, s string) { 179 | m.FrameworkMessageFnInvoked = true 180 | m.FrameworkMessageFn(sd, eID, sID, s) 181 | } 182 | 183 | // SlaveLost mocks the SlaveLost functionality 184 | func (m *MesosScheduler) SlaveLost(s scheduler.SchedulerDriver, sID *mesosproto.SlaveID) { 185 | m.SlaveLostFnInvoked = true 186 | m.SlaveLostFn(s, sID) 187 | } 188 | 189 | // ExecutorLost mocks the ExecutorLost functionality 190 | func (m *MesosScheduler) ExecutorLost(sd scheduler.SchedulerDriver, eID *mesosproto.ExecutorID, sID *mesosproto.SlaveID, i int) { 191 | m.ExecutorLostFnInvoked = true 192 | m.ExecutorLostFn(sd, eID, sID, i) 193 | } 194 | 195 | // Error mocks the Error functionality 196 | func (m *MesosScheduler) Error(d scheduler.SchedulerDriver, msg string) { 197 | m.ErrorFnInvoked = true 198 | m.ErrorFn(d, msg) 199 | } 200 | -------------------------------------------------------------------------------- /server/handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "io/ioutil" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/sirupsen/logrus" 14 | "github.com/elazarl/go-bindata-assetfs" 15 | "github.com/gorilla/mux" 16 | "github.com/gorilla/schema" 17 | 18 | "github.com/eremetic-framework/eremetic" 19 | "github.com/eremetic-framework/eremetic/api" 20 | "github.com/eremetic-framework/eremetic/config" 21 | "github.com/eremetic-framework/eremetic/server/assets" 22 | "github.com/eremetic-framework/eremetic/version" 23 | ) 24 | 25 | type errorDocument struct { 26 | Error string `json:"error"` 27 | Message string `json:"message"` 28 | } 29 | 30 | // Handler holds the server context. 31 | type Handler struct { 32 | scheduler eremetic.Scheduler 33 | database eremetic.TaskDB 34 | } 35 | 36 | // NewHandler returns a new instance of Handler. 37 | func NewHandler(scheduler eremetic.Scheduler, database eremetic.TaskDB) Handler { 38 | return Handler{ 39 | scheduler: scheduler, 40 | database: database, 41 | } 42 | } 43 | 44 | // AddTask handles adding a task to the queue 45 | func (h Handler) AddTask(conf *config.Config, apiVersion string) http.HandlerFunc { 46 | return func(w http.ResponseWriter, r *http.Request) { 47 | var request eremetic.Request 48 | 49 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) 50 | if err != nil { 51 | handleError(err, w, "Unable to read payload.") 52 | return 53 | } 54 | format := "" 55 | switch apiVersion { 56 | case api.V0: 57 | deprecated(w) 58 | var req api.RequestV0 59 | err = json.Unmarshal(body, &req) 60 | request = api.RequestFromV0(req) 61 | format = "/task/%s" 62 | if err != nil { 63 | handleError(err, w, "Unable to parse body into a valid request.") 64 | return 65 | } 66 | case api.V1: 67 | var req api.RequestV1 68 | err = json.Unmarshal(body, &req) 69 | request = api.RequestFromV1(req) 70 | format = "/api/v1/task/%s" 71 | if err != nil { 72 | handleError(err, w, "Unable to parse body into a valid request.") 73 | return 74 | } 75 | default: 76 | handleError(errors.New("Invalid API version"), w, "Invalid API version.") 77 | return 78 | } 79 | 80 | taskID, err := h.scheduler.ScheduleTask(request) 81 | location := fmt.Sprintf(format, taskID) 82 | 83 | if err != nil { 84 | logrus.WithError(err).Error("Unable to create task.") 85 | httpStatus := 500 86 | if err == eremetic.ErrQueueFull { 87 | httpStatus = 503 88 | } 89 | errorMessage := errorDocument{ 90 | err.Error(), 91 | "Unable to schedule task", 92 | } 93 | writeJSON(httpStatus, errorMessage, w) 94 | return 95 | } 96 | 97 | w.Header().Set("Location", absURL(r, location, conf)) 98 | writeJSON(http.StatusAccepted, taskID, w) 99 | } 100 | } 101 | 102 | // GetFromSandbox fetches a file from the sandbox of the agent that ran the task 103 | func (h Handler) GetFromSandbox(file string, apiVersion string) http.HandlerFunc { 104 | return func(w http.ResponseWriter, r *http.Request) { 105 | if apiVersion == api.V0 { 106 | deprecated(w) 107 | } 108 | vars := mux.Vars(r) 109 | taskID := vars["taskId"] 110 | task, _ := h.database.ReadTask(taskID) 111 | 112 | status, data := getFile(file, task) 113 | 114 | if status != http.StatusOK { 115 | writeJSON(status, data, w) 116 | return 117 | } 118 | 119 | defer data.Close() 120 | w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 121 | w.WriteHeader(http.StatusOK) 122 | io.Copy(w, data) 123 | } 124 | } 125 | 126 | // GetTaskInfo returns information about the given task. 127 | func (h Handler) GetTaskInfo(conf *config.Config, apiVersion string) http.HandlerFunc { 128 | return func(w http.ResponseWriter, r *http.Request) { 129 | vars := mux.Vars(r) 130 | id := vars["taskId"] 131 | logrus.WithField("task_id", id).Debug("Fetching task") 132 | task0, _ := h.database.ReadTask(id) 133 | switch apiVersion { 134 | case api.V0: 135 | deprecated(w) 136 | getTaskInfoV0(task0, conf, id, w, r) 137 | case api.V1: 138 | getTaskInfoV1(task0, conf, id, w, r) 139 | } 140 | } 141 | } 142 | 143 | // ListTasks returns information about running tasks in the database. 144 | func (h Handler) ListTasks(apiVersion string) http.HandlerFunc { 145 | return func(w http.ResponseWriter, r *http.Request) { 146 | filter := &eremetic.TaskFilter{ 147 | State: eremetic.DefaultTaskFilterState, 148 | } 149 | if err := schema.NewDecoder().Decode(filter, r.URL.Query()); err != nil { 150 | handleError(err, w, "Unable to parse query params") 151 | return 152 | } 153 | logrus.Debug("Fetching all tasks") 154 | tasks, err := h.database.ListTasks(filter) 155 | if err != nil { 156 | handleError(err, w, "Unable to fetch running tasks from the database") 157 | return 158 | } 159 | switch apiVersion { 160 | case api.V0: 161 | deprecated(w) 162 | tasksV0 := []api.TaskV0{} 163 | for _, t := range tasks { 164 | tasksV0 = append(tasksV0, api.TaskV0FromTask(t)) 165 | } 166 | writeJSON(200, tasksV0, w) 167 | case api.V1: 168 | tasksV1 := []api.TaskV1{} 169 | for _, t := range tasks { 170 | tasksV1 = append(tasksV1, api.TaskV1FromTask(t)) 171 | } 172 | writeJSON(200, tasksV1, w) 173 | } 174 | } 175 | } 176 | 177 | // IndexHandler returns the index template, or no content. 178 | func (h Handler) IndexHandler(conf *config.Config) http.HandlerFunc { 179 | return func(w http.ResponseWriter, r *http.Request) { 180 | if strings.Contains(r.Header.Get("Accept"), "text/html") { 181 | src, _ := assets.Asset("templates/index.html") 182 | tpl, err := template.New("index").Parse(string(src)) 183 | data := make(map[string]interface{}) 184 | data["Version"] = version.Version 185 | data["URLPrefix"] = conf.URLPrefix 186 | if err == nil { 187 | tpl.Execute(w, data) 188 | return 189 | } 190 | logrus.WithError(err).WithField("template", "index.html").Error("Unable to load template") 191 | } 192 | 193 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 194 | w.WriteHeader(http.StatusNoContent) 195 | json.NewEncoder(w).Encode(nil) 196 | } 197 | } 198 | 199 | // Version returns the currently running Eremetic version. 200 | func (h Handler) Version(conf *config.Config, apiVersion string) http.HandlerFunc { 201 | return func(w http.ResponseWriter, r *http.Request) { 202 | if apiVersion == api.V0 { 203 | deprecated(w) 204 | } 205 | w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 206 | w.WriteHeader(http.StatusOK) 207 | json.NewEncoder(w).Encode(version.Version) 208 | } 209 | } 210 | 211 | // NotFound is in charge of reporting that a task can not be found. 212 | func (h Handler) NotFound(conf *config.Config) http.HandlerFunc { 213 | return func(w http.ResponseWriter, r *http.Request) { 214 | // Proxy to the notFound helper function 215 | notFound(w, r, conf) 216 | } 217 | } 218 | 219 | // StaticAssets handles the serving of compiled static assets. 220 | func (h Handler) StaticAssets() http.Handler { 221 | return http.StripPrefix( 222 | "/static/", http.FileServer( 223 | &assetfs.AssetFS{Asset: assets.Asset, AssetDir: assets.AssetDir, AssetInfo: assets.AssetInfo, Prefix: "static"})) 224 | } 225 | 226 | // KillTask handles killing a task. 227 | func (h Handler) KillTask(conf *config.Config, apiVersion string) http.HandlerFunc { 228 | return func(w http.ResponseWriter, r *http.Request) { 229 | if apiVersion == api.V0 { 230 | deprecated(w) 231 | } 232 | vars := mux.Vars(r) 233 | id := vars["taskId"] 234 | logrus.WithField("task_id", id).Debug("Killing task") 235 | err := h.scheduler.Kill(id) 236 | respStatus := http.StatusAccepted 237 | var body string 238 | if err != nil { 239 | respStatus = http.StatusInternalServerError 240 | body = err.Error() 241 | } 242 | writeJSON(respStatus, body, w) 243 | } 244 | } 245 | 246 | // DeleteTask takes care of API calls to remove a task 247 | func (h Handler) DeleteTask(conf *config.Config, apiVersion string) http.HandlerFunc { 248 | return func(w http.ResponseWriter, r *http.Request) { 249 | if apiVersion == api.V0 { 250 | deprecated(w) 251 | } 252 | vars := mux.Vars(r) 253 | id := vars["taskId"] 254 | logrus.WithField("task_id", id).Debug("Deleting task") 255 | respStatus := http.StatusAccepted 256 | var body string 257 | task, err := h.database.ReadTask(id) 258 | if err != nil { 259 | respStatus = http.StatusNotFound 260 | writeJSON(respStatus, err.Error(), w) 261 | return 262 | } 263 | if task.IsRunning() { 264 | respStatus = http.StatusConflict 265 | errMsg := fmt.Sprintf("Cannot delete the task [%s]. As it is still running.", id) 266 | logrus.WithField("task_id", id).Debug(errMsg) 267 | writeJSON(respStatus, errMsg, w) 268 | return 269 | } 270 | err = h.database.DeleteTask(id) 271 | if err != nil { 272 | respStatus = http.StatusInternalServerError 273 | body = err.Error() 274 | } 275 | writeJSON(respStatus, body, w) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eremetic 2 | 3 | [![Build Status][travis-image]](https://travis-ci.org/eremetic-framework/eremetic) 4 | [![Coverage Status][coveralls-image]](https://coveralls.io/r/eremetic-framework/eremetic?branch=master) 5 | [![Go Report][goreport-image]](https://goreportcard.com/report/github.com/eremetic-framework/eremetic) 6 | 7 | ## Purpose 8 | Eremetic is a Mesos Framework to run one-shot tasks. The vision is to provide a 9 | bridge between Applications that need to run tasks and Mesos. That way a developer 10 | creating an application that needs to schedule tasks (such as cron) wouldn't need 11 | to connect to Mesos directly. 12 | 13 | ## Usage 14 | Send a cURL to the eremetic framework with how much cpu and memory you need, what docker image to run and which command to run with that image. 15 | 16 | ```bash 17 | curl -H "Content-Type: application/json" \ 18 | -X POST \ 19 | -d '{"mem":22.0, "cpu":1.0, "image": "busybox", "command": "echo $(date)"}' \ 20 | http://eremetic_server:8080/api/v1/task 21 | ``` 22 | 23 | These basic fields are required but you can also specify volumes, container names to mounts volumes from, ports, environment 24 | variables, and URIs for the mesos fetcher to download. See 25 | [examples.md](examples.md) for more examples on how to use eremetic. 26 | 27 | JSON format: 28 | 29 | ```javascript 30 | { 31 | // Float64, fractions of a CPU to request 32 | "cpu": 1.0, 33 | // Float64, memory to use (MiB) 34 | "mem": 22.0, 35 | // String, full tag or hash of container to run 36 | "image": "busybox", 37 | // Boolean, if set to true, docker image will be pulled before each task launch 38 | "force_pull_image": false, 39 | // Boolean, if set to true, docker will run the container in 'privileged' mode giving it all capabilities 40 | "privileged": false, 41 | // String, command to run in the docker container 42 | "command": "echo $(date)", 43 | // Array of Strings, arguements to pass to the docker container entrypoint 44 | "args": ["+%s"], 45 | // Array of Objects, volumes to mount in the container 46 | "volumes": [ 47 | { 48 | "container_path": "/var/run/docker.sock", 49 | "host_path": "/var/run/docker.sock" 50 | } 51 | ], 52 | // Array of Strings, container names to get volumes from 53 | "volumes_from": ["+%s"], 54 | //String, name of the task. If empty, Eremetic assigns a random task name 55 | "name" : "Task Name", 56 | //String, network mode to pass to the container. 57 | "network" : "BRIDGE", 58 | //String, DNS address to be used by the container. 59 | "dns" : "172.0.0.2", 60 | // Array of Objects, ports to forward to the container. 61 | // Assigned host ports are available as environment variables (e.g. PORT0, PORT1 and so on with PORT being an alias for PORT0). 62 | "ports": [ 63 | { 64 | "container_port": 80, 65 | "protocol": "tcp" 66 | } 67 | ], 68 | // Object, Environment variables to pass to the container 69 | "env": { 70 | "KEY": "value" 71 | }, 72 | // Object, Will be merged to `env` when passed to Mesos, but masked when doing a GET. 73 | // See Clarification of the Masked Env field below for more information 74 | "masked_env": { 75 | "KEY": "value" 76 | }, 77 | // Object, labels to be passed to the Mesos task 78 | "labels": { 79 | "KEY": "value" 80 | }, 81 | // URIs and attributes of resource to download. You need to explicitly define 82 | // `"extract"` to unarchive files. 83 | "fetch": [ 84 | { 85 | "uri" : "http://server.local/another_resource", 86 | "extract": false, 87 | "executable": false, 88 | "cache": false 89 | } 90 | ], 91 | // Constraints for which agent the task can run on (beyond cpu/memory). 92 | // Matching is strict and only attributes are currently supported. If 93 | // multiple constraints exist, they are evaluated using AND (ie: all or none). 94 | "agent_constraints": [ 95 | { 96 | "attribute_name": "aws-region", 97 | "attribute_value": "us-west-2" 98 | } 99 | ], 100 | // String, URL to post a callback to. Callback message has format: 101 | // {"time":1451398320,"status":"TASK_FAILED","task_id":"eremetic-task.79feb50d-3d36-47cf-98ff-a52ef2bc0eb5"} 102 | "callback_uri": "http://callback.local" 103 | } 104 | ``` 105 | 106 | ### Note 107 | Most of this meta-data will not remain after a full restart of Eremetic. 108 | 109 | ### Clarification of the Masked Env field 110 | The purpose of the field is to provide a way to pass along environment variables that you don't want to have exposed in a subsequent GET call. 111 | It is not intended to provide full security, as someone with access to either the machine running Eremetic or the Mesos Agent that the task is being run on will still be able to view these values. 112 | These values are not encrypted, but simply masked when retrieved back via the API. 113 | 114 | For security purposes, ensure TLS (https) is being used for the Eremetic communication and that access to any machines is properly restricted. 115 | 116 | 117 | ## Configuration 118 | create /etc/eremetic/eremetic.yml with: 119 | 120 | address: 0.0.0.0 121 | port: 8080 122 | master: zk://,,(...)/mesos 123 | messenger_address: 124 | messenger_port: 125 | loglevel: DEBUG 126 | logformat: json 127 | queue_size: 100 128 | url_prefix: 129 | 130 | ## Database 131 | Eremetic uses a database to store task information. The driver can be configured 132 | by setting the `database_driver` value. 133 | 134 | Allowed values are: `zk`, `boltdb` 135 | 136 | The location of the database can be configured by setting the `database` value. 137 | 138 | ### BoltDB 139 | The default database that will be used unless anything is configured. 140 | 141 | The default value of the `database` field is `db/eremetic.db` 142 | 143 | ### ZooKeeper 144 | If you use `zk` as a database driver, the `database` field must be provided as a 145 | complete zk-uri (zk://zk1:1234,zk2:1234/my/database). 146 | 147 | ## Authentication 148 | To enable mesos framework authentication add the location of credential file to your configuration: 149 | 150 | credential_file: /var/mesos_secret 151 | 152 | The file should contain the Principal to authenticate and the secret separated by white space like so: 153 | 154 | principal secret_key 155 | 156 | ## Building 157 | 158 | ### Environment 159 | Clone the repository into `$GOPATH/src/github.com/eremetic-framework/eremetic`. 160 | This is needed because of internal package dependencies 161 | 162 | ### Install dependencies 163 | First you need to install dependencies. Parts of the eremetic code is auto-generated (assets and templates for the HTML view are compiled). In order for go generate to work, `go-bindata` and `go-bindata-assetfs` needs to be manually installed. 164 | 165 | curl https://bin.equinox.io/a/75VeNN6mcnk/github-com-kevinburke-go-bindata-go-bindata-linux-amd64.tar.gz | tar xvf - -C /usr/local/bin 166 | go get github.com/elazarl/go-bindata-assetfs/... 167 | 168 | All other dependencies are vendored, so it is recommended to run eremetic with Go >= 1.6 or with GO15VENDOREXPERIMENT=1 169 | 170 | ### Creating the docker image 171 | To build a docker image with eremetic, simply run 172 | 173 | make docker 174 | 175 | ### Compiling 176 | Run `make eremetic` 177 | 178 | ## Running on mesos 179 | 180 | Eremetic can itself be run on mesos using e.g marathon. An 181 | [example configuration](misc/eremetic.json) for marathon is provided that is 182 | ready to be submitted through the api. 183 | 184 | ```bash 185 | curl -X POST -H 'Content-Type: application/json' $MARATHON/v2/apps -d@misc/eremetic.json 186 | ``` 187 | 188 | ## Running tests 189 | The default target of make builds and runs tests. 190 | Tests can also be run by running `goconvey` in the project root. 191 | 192 | ## Running with minimesos 193 | Using [minimesos](https://www.minimesos.org/) is a very simple way to test and play with eremetic. 194 | 195 | ```bash 196 | docker run -e MASTER=$MINIMESOS_ZOOKEEPER -e HOST=0.0.0.0 -e DATABASE_DRIVER=zk -e DATABASE=$MINIMESOS_ZOOKEEPER/eremetic -e PORT=8000 -p 8000:8000 alde/eremetic:latest 197 | ``` 198 | 199 | ## hermit CLI 200 | 201 | [hermit](cmd/hermit) is a command-line application to perform operations on a Eremetic server from the terminal. 202 | 203 | ## Contributors 204 | 205 | These are the fine folks who helped build eremetic 206 | 207 | - Rickard Dybeck 208 | - David Keijser 209 | - Aidan McGinley 210 | - William Strucke 211 | - Charles G. 212 | - Clément Laforet 213 | - Marcus Olsson 214 | - Rares Mirica 215 | 216 | ## Acknowledgements 217 | Thanks to Sebastian Norde for the awesome logo! 218 | 219 | ## Licensing 220 | Apache-2 221 | 222 | [travis-image]: https://img.shields.io/travis/eremetic-framework/eremetic.svg?style=flat 223 | [coveralls-image]: https://img.shields.io/coveralls/eremetic-framework/eremetic.svg?style=flat 224 | [goreport-image]: https://goreportcard.com/badge/github.com/eremetic-framework/eremetic 225 | --------------------------------------------------------------------------------