├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.go ├── api_test.go ├── auth.go ├── auth_test.go ├── instance.go ├── instance_ip.go ├── instance_log.go ├── instance_test.go ├── logger.go ├── proxy.go ├── util.go └── util_test.go ├── client ├── client.go ├── client_auth.go ├── client_flow.go ├── client_nodes.go ├── client_test.go ├── types_auth.go ├── types_flows.go ├── types_nodes.go └── types_settings.go ├── config.example.yml ├── config └── config.go ├── data ├── instances │ └── .gitkeep └── store │ └── .gitkeep ├── docker-compose.yml ├── docker ├── client.go ├── events.go ├── images.go ├── images_test.go ├── logs.go ├── network.go ├── network_test.go ├── remove.go ├── start.go ├── start_test.go └── stop.go ├── go.mod ├── go.sum ├── main.go ├── model ├── config.go └── instance.go ├── service └── cli.go ├── storage ├── fileutil.go ├── storage.go └── store.go └── test ├── Dockerfile └── build_fail └── Dockerfile /.gitignore: -------------------------------------------------------------------------------- 1 | instances/* 2 | custom-nodes/* 3 | logs/* 4 | tmp/* 5 | data/* 6 | redzilla 7 | debug 8 | /config.yml 9 | /vendor 10 | /test/data 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ENV GIN_MODE=release 3 | COPY ./redzilla /redzilla 4 | ENTRYPOINT [ "/redzilla" ] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Luca Capra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | TAG := $(shell git describe --tags | grep "^[v0-9.]*" -o) 3 | 4 | run: 5 | go run main.go 6 | 7 | clean: 8 | rm -f redzilla 9 | 10 | build: 11 | CGO_ENABLED=0 go build -a -ldflags '-s' -o redzilla 12 | 13 | docker/build: build 14 | docker build . -t opny/redzilla:$(TAG) 15 | docker tag opny/redzilla:$(TAG) opny/redzilla:latest 16 | 17 | docker/push: 18 | docker push opny/redzilla:$(TAG) 19 | docker push opny/redzilla:latest 20 | 21 | docker/release: docker/build docker/push 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redzilla 2 | 3 | `redzilla` manage multiple instances of [node-red](http://nodered.org/) 4 | 5 | ## Usage 6 | 7 | Start the service with `docker-compose`, by default it will run on port `3000` 8 | 9 | `docker-compose up -d` 10 | 11 | Create a new instance named `hello-world` 12 | 13 | `curl -X POST http://redzilla.localhost:3000/v2/instances/hello-world` 14 | 15 | Open in the browser 16 | 17 | `xdg-open http://hello-world.redzilla.localhost:3000/` 18 | 19 | Done! 20 | 21 | ### Using custom images 22 | 23 | `ImageName` option allow to use local or remote custom images. Example: 24 | 25 | - `ImageName: docker.io/nodered/node-red-docker:latest` Use the latest online version 26 | - `ImageName: mycustom/nodered:latest` Use the `mycustom/nodered` local image 27 | 28 | 29 | ## Configuration 30 | 31 | See `config.example.yml` for configuration options. 32 | 33 | ### Environment variables 34 | 35 | - `REDZILLA_NETWORK` (default: `redzilla`) set the network where node-red instances will run 36 | - `REDZILLA_APIPORT` (default: `:3000`) changes the API host:port to listen for 37 | - `REDZILLA_DOMAIN` (default: `redzilla.localhost`) set the base domain to listen for 38 | - `REDZILLA_IMAGENAME` (default: `nodered/node-red-docker`) changes the `node-red` image to be spawn (must be somehow compatible to the official one) 39 | - `REDZILLA_STOREPATH` (default: `./data/store`) file store for the container runtime metadata 40 | - `REDZILLA_INSTANCEDATAPATH` (default: `./data/instances`) container instaces data (like setting.js and flows.json) 41 | - `REDZILLA_LOGLEVEL` (default: `info`) log level detail 42 | - `REDZILLA_AUTOSTART` (default: `false`) allow to create a new instance when reaching an activable subdomain 43 | - `REDZILLA_ENVPREFIX` (empty by default) filter environment variables by prefix and pass to the created instance. 44 | Empty means no ENV are passed. The `${PREFIX}_` string will be removed from the variable name before passing to the instance. Example `NODERED` will match `NODERED_`, `RED` will match `REDZILLA_` and `RED_` 45 | - `REDZILLA_CONFIG` load a configuration file (see `config.example.yml` for reference) 46 | 47 | ## API 48 | 49 | List instances 50 | 51 | `curl -X GET http://redzilla.localhost:3000/v2/instances` 52 | 53 | Create or start an instance 54 | 55 | `curl -X POST http://redzilla.localhost:3000/v2/instances/instance-name` 56 | 57 | Restart an instance (stop + start) 58 | 59 | `curl -X POST http://redzilla.localhost:3000/v2/instances/instance-name` 60 | 61 | Stop an instance 62 | 63 | `curl -X DELETE http://redzilla.localhost:3000/v2/instances/instance-name` 64 | 65 | ## Prerequisites 66 | 67 | To run `redzilla` you need `docker` and `docker-compose` installed. 68 | 69 | ## License 70 | 71 | The MIT license. See `LICENSE` file for details 72 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/muka/redzilla/model" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | //NodeRedPort default internal node red port 12 | const NodeRedPort = "1880" 13 | 14 | //Start start API HTTP server 15 | func Start(cfg *model.Config) error { 16 | 17 | router := gin.Default() 18 | 19 | if len(cfg.AuthType) > 0 && cfg.AuthType != "none" { 20 | router.Use(AuthHandler(cfg)) 21 | } 22 | 23 | router.Any("/v2/instances/:name", func(c *gin.Context) { 24 | 25 | if !isRootDomain(c.Request.Host, cfg.Domain) { 26 | c.Next() 27 | return 28 | } 29 | 30 | logrus.Debugf("Api call %s %s", c.Request.Method, c.Request.URL.Path) 31 | 32 | name, err := validateName(c.Param("name")) 33 | if err != nil { 34 | c.AbortWithError(http.StatusBadRequest, err) 35 | return 36 | } 37 | 38 | instance := GetInstance(name, cfg) 39 | if instance == nil { 40 | notFound(c) 41 | return 42 | } 43 | 44 | logrus.Debugf("Got %s %s", c.Request.Method, name) 45 | 46 | switch c.Request.Method { 47 | case http.MethodGet: 48 | logrus.Debugf("Load instance %s", name) 49 | 50 | if !instanceExists(c, instance) { 51 | return 52 | } 53 | 54 | c.JSON(http.StatusOK, instance.GetStatus()) 55 | 56 | break 57 | case http.MethodPost: 58 | 59 | logrus.Debugf("Start instance %s", name) 60 | 61 | err := instance.Start() 62 | if err != nil { 63 | internalError(c, err) 64 | return 65 | } 66 | 67 | c.JSON(http.StatusOK, instance.GetStatus()) 68 | 69 | break 70 | case http.MethodPut: 71 | logrus.Debugf("Restart instance %s", name) 72 | 73 | if !instanceExists(c, instance) { 74 | return 75 | } 76 | 77 | err := instance.Restart() 78 | if err != nil { 79 | internalError(c, err) 80 | return 81 | } 82 | 83 | c.JSON(http.StatusOK, instance.GetStatus()) 84 | 85 | break 86 | case http.MethodDelete: 87 | logrus.Debugf("Stop instance %s", name) 88 | 89 | if !instanceExists(c, instance) { 90 | return 91 | } 92 | 93 | err := instance.Stop() 94 | if err != nil { 95 | errorResponse(c, http.StatusInternalServerError, err.Error()) 96 | return 97 | } 98 | 99 | c.Status(http.StatusAccepted) 100 | 101 | break 102 | default: 103 | badRequest(c) 104 | break 105 | } 106 | 107 | }) 108 | 109 | router.GET("/v2/instances", func(c *gin.Context) { 110 | 111 | if !isRootDomain(c.Request.Host, cfg.Domain) { 112 | c.Next() 113 | return 114 | } 115 | 116 | logrus.Debug("List instances") 117 | list, err := ListInstances(cfg) 118 | if err != nil { 119 | logrus.Errorf("ListInstances: %s", err) 120 | internalError(c, err) 121 | return 122 | } 123 | 124 | c.JSON(http.StatusOK, list) 125 | }) 126 | 127 | // reverse proxy 128 | router.Use(proxyHandler(cfg)) 129 | 130 | logrus.Infof("Starting API at %s", cfg.APIPort) 131 | return router.Run(cfg.APIPort) 132 | } 133 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | httpclient "github.com/ddliu/go-httpclient" 10 | "github.com/muka/redzilla/config" 11 | "github.com/muka/redzilla/model" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestAPI(t *testing.T) { 16 | 17 | err := config.Init() 18 | assert.NoError(t, err) 19 | 20 | cfg, err := config.GetApiConfig() 21 | assert.NoError(t, err) 22 | 23 | cfg.APIPort = ":50015" 24 | cfg.Autostart = true 25 | cfg.StorePath = "../test/data/store" 26 | cfg.InstanceDataPath = "../test/data/instances" 27 | cfg.InstanceConfigPath = "../test/data/config" 28 | 29 | defer CloseInstanceLoggers() 30 | 31 | go func() { 32 | err = Start(cfg) 33 | assert.NoError(t, err) 34 | }() 35 | 36 | baseUrl := fmt.Sprintf("http://%s%s/v2/instances", cfg.Domain, cfg.APIPort) 37 | res, err := httpclient.PostJson(baseUrl+"/test1", nil) 38 | assert.NoError(t, err) 39 | assert.Equal(t, http.StatusOK, res.StatusCode) 40 | 41 | b, err := res.ReadAll() 42 | assert.NoError(t, err) 43 | 44 | instance := new(model.Instance) 45 | err = json.Unmarshal(b, instance) 46 | assert.NoError(t, err) 47 | 48 | assert.NotEmpty(t, instance.Name) 49 | assert.NotEmpty(t, instance.IP) 50 | 51 | _, err = httpclient.Delete(baseUrl + "/test1") 52 | assert.NoError(t, err) 53 | assert.Equal(t, http.StatusOK, res.StatusCode) 54 | 55 | res, err = httpclient.Get(baseUrl) 56 | assert.NoError(t, err) 57 | assert.Equal(t, http.StatusOK, res.StatusCode) 58 | 59 | b, err = res.ReadAll() 60 | assert.NoError(t, err) 61 | 62 | instances := make([]model.Instance, 0) 63 | err = json.Unmarshal(b, &instances) 64 | assert.NoError(t, err) 65 | 66 | } 67 | -------------------------------------------------------------------------------- /api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/muka/redzilla/model" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | //RequestBodyTemplate contains params avail in the body template 16 | type RequestBodyTemplate struct { 17 | Url string 18 | Method string 19 | Name string 20 | HeaderKey string 21 | HeaderVal string 22 | } 23 | 24 | // AuthHandler handle authentication and authorization 25 | func AuthHandler(cfg *model.Config) func(c *gin.Context) { 26 | return func(c *gin.Context) { 27 | 28 | switch strings.ToLower(cfg.AuthType) { 29 | case "http": 30 | 31 | reqArgs := new(RequestBodyTemplate) 32 | reqArgs.Url = c.Request.URL.String() 33 | reqArgs.Method = c.Request.Method 34 | reqArgs.Name = c.Param("name") 35 | reqArgs.HeaderKey = cfg.AuthHttp.Header 36 | reqArgs.HeaderVal = c.Request.Header.Get(cfg.AuthHttp.Header) 37 | 38 | res, err := doRequest(reqArgs, cfg.AuthHttp) 39 | if err != nil { 40 | c.AbortWithStatus(http.StatusInternalServerError) 41 | return 42 | } 43 | 44 | if res { 45 | c.Next() 46 | return 47 | } 48 | 49 | c.AbortWithStatus(401) 50 | 51 | break 52 | case "none": 53 | case "": 54 | //no auth, go on 55 | } 56 | } 57 | } 58 | 59 | func doRequest(reqArgs *RequestBodyTemplate, a *model.AuthHttp) (bool, error) { 60 | 61 | url := a.URL 62 | method := strings.ToUpper(a.Method) 63 | bodyTemplate := a.Body 64 | 65 | var body bytes.Buffer 66 | 67 | err := bodyTemplate.Execute(&body, reqArgs) 68 | if err != nil { 69 | logrus.Warnf("Template execution failed: %s", err) 70 | return false, err 71 | } 72 | 73 | client := new(http.Client) 74 | client.Timeout = time.Duration(2 * time.Second) 75 | req, err := http.NewRequest(method, url, &body) 76 | if err != nil { 77 | logrus.Warnf("Auth request creation failed: %s", err) 78 | return false, err 79 | } 80 | 81 | req.Header.Add(reqArgs.HeaderKey, reqArgs.HeaderVal) 82 | resp, err := client.Do(req) 83 | if err != nil { 84 | logrus.Warnf("Auth request creation failed: %s", err) 85 | return false, err 86 | } 87 | 88 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { 89 | return true, nil 90 | } 91 | if resp.StatusCode >= 500 { 92 | logrus.Warnf("Auth request failed with code %d", resp.StatusCode) 93 | body, err := ioutil.ReadAll(resp.Body) 94 | if err == nil { 95 | logrus.Warnf("Response body: %s", string(body)) 96 | } 97 | return false, err 98 | } 99 | 100 | logrus.Debugf("Request unauthorized %s %s [response code: %d]", reqArgs.Method, reqArgs.Url, resp.StatusCode) 101 | return false, nil 102 | } 103 | -------------------------------------------------------------------------------- /api/auth_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io/ioutil" 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/muka/redzilla/model" 12 | ) 13 | 14 | func TestAuthReqPost(t *testing.T) { 15 | 16 | reqArgs := &RequestBodyTemplate{ 17 | HeaderKey: "Authorization", 18 | HeaderVal: "Bearer foobar", 19 | Method: "POST", 20 | Name: "myInstance", 21 | Url: "/v2/instances/myInstance", 22 | } 23 | 24 | tmpl, err := template.New("").Parse(`{ "foo": "bar", "instance": "{{.Name}}" }`) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | a := &model.AuthHttp{ 30 | Body: tmpl, 31 | Header: "Authorization", 32 | Method: "POST", 33 | URL: "http://localhost:50999/test", 34 | } 35 | 36 | go func() { 37 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | headerValue := r.Header.Get(reqArgs.HeaderKey) 39 | body, err1 := ioutil.ReadAll(r.Body) 40 | if err1 != nil { 41 | t.Fatal(err1) 42 | return 43 | } 44 | 45 | fmt.Printf("Got request `%s %s`\n", r.Method, r.URL.String()) 46 | fmt.Printf("Header `%s`\n", headerValue) 47 | fmt.Printf("Body `%s`\n", body) 48 | 49 | w.WriteHeader(http.StatusUnauthorized) 50 | }) 51 | err1 := http.ListenAndServe("localhost:50999", h) 52 | if err1 != nil { 53 | t.Fatal(err1) 54 | } 55 | }() 56 | 57 | time.Sleep(time.Millisecond * 500) 58 | 59 | res, err := doRequest(reqArgs, a) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | fmt.Printf("Result %t\n", res) 65 | time.Sleep(time.Millisecond * 500) 66 | } 67 | -------------------------------------------------------------------------------- /api/instance.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/muka/redzilla/docker" 10 | "github.com/muka/redzilla/model" 11 | "github.com/muka/redzilla/storage" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | const instanceCollection = "instances" 16 | 17 | var instancesCache = make(map[string]*Instance) 18 | 19 | //ListInstances list available instances 20 | func ListInstances(cfg *model.Config) (*[]model.Instance, error) { 21 | 22 | store := storage.GetStore(instanceCollection, cfg) 23 | 24 | jsonlist, err := store.List() 25 | if err != nil { 26 | return nil, fmt.Errorf("store.List: %s", err) 27 | } 28 | 29 | list := make([]model.Instance, 0) 30 | for _, jsonstr := range jsonlist { 31 | item := new(model.Instance) 32 | err = json.Unmarshal([]byte(jsonstr), item) 33 | if err != nil { 34 | return nil, err 35 | } 36 | list = append(list, *item) 37 | } 38 | 39 | logrus.Debugf("Found %d instances", len(list)) 40 | return &list, err 41 | } 42 | 43 | // GetInstance return a instance from the cache if available 44 | func GetInstance(name string, cfg *model.Config) *Instance { 45 | if _, ok := instancesCache[name]; !ok { 46 | instancesCache[name] = NewInstance(name, cfg) 47 | } 48 | return instancesCache[name] 49 | } 50 | 51 | // NewInstance new instance api 52 | func NewInstance(name string, cfg *model.Config) *Instance { 53 | 54 | datadir := storage.GetInstancesDataPath(name, cfg) 55 | storage.CreateDir(datadir) 56 | 57 | instanceLogger, err := NewInstanceLogger(name, datadir) 58 | if err != nil { 59 | logrus.Errorf("Failed to initialize instance %s logger at %s", name, datadir) 60 | panic(err) 61 | } 62 | 63 | i := Instance{ 64 | instance: model.NewInstance(name), 65 | cfg: cfg, 66 | store: storage.GetStore(instanceCollection, cfg), 67 | logger: instanceLogger, 68 | logContext: NewInstanceContext(), 69 | } 70 | 71 | // TODO add support to port mapping (eg. MQTT) 72 | i.instance.Port = NodeRedPort 73 | 74 | return &i 75 | } 76 | 77 | //NewInstanceContext craeate a new instance context 78 | func NewInstanceContext() *InstanceContext { 79 | ctx, cancel := context.WithCancel(context.Background()) 80 | return &InstanceContext{ 81 | context: ctx, 82 | cancel: cancel, 83 | } 84 | } 85 | 86 | //InstanceContext tracks internal context for the instance 87 | type InstanceContext struct { 88 | context context.Context 89 | cancel context.CancelFunc 90 | } 91 | 92 | //Cancel the instance level context 93 | func (c *InstanceContext) Cancel() { 94 | c.cancel() 95 | } 96 | 97 | //GetContext return the real context reference 98 | func (c *InstanceContext) GetContext() context.Context { 99 | return c.context 100 | } 101 | 102 | //Instance API 103 | type Instance struct { 104 | instance *model.Instance 105 | cfg *model.Config 106 | store *storage.Store 107 | logger *InstanceLogger 108 | logContext *InstanceContext 109 | } 110 | 111 | //Save instance status 112 | func (i *Instance) Save() error { 113 | 114 | logrus.Debugf("Saving instance state %s", i.instance.Name) 115 | 116 | err := i.store.Save(i.instance.Name, i.instance) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | 124 | //Create instance without starting 125 | func (i *Instance) Create() error { 126 | 127 | logrus.Debugf("Creating instance %s", i.instance.Name) 128 | 129 | err := i.Save() 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return nil 135 | } 136 | 137 | //Start an instance creating a record for if it does not exists 138 | func (i *Instance) Start() error { 139 | 140 | logrus.Debugf("Starting instance %s", i.instance.Name) 141 | 142 | err := i.Save() 143 | if err != nil { 144 | return err 145 | } 146 | 147 | err = docker.StartContainer(i.instance.Name, i.cfg) 148 | if err != nil { 149 | return fmt.Errorf("StartContainer: %s", err) 150 | } 151 | 152 | _, err = i.GetIP() 153 | if err != nil { 154 | return fmt.Errorf("GetIP: %s", err) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | //Remove instance and stop it if running 161 | func (i *Instance) Remove() error { 162 | 163 | err := i.Stop() 164 | if err != nil { 165 | return err 166 | } 167 | 168 | err = i.store.Delete(i.instance.Name) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | return nil 174 | } 175 | 176 | //Stop instance without removing 177 | func (i *Instance) Stop() error { 178 | 179 | logrus.Debugf("Stopping instance %s", i.instance.Name) 180 | 181 | err := docker.StopContainer(i.instance.Name, false) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | return nil 187 | } 188 | 189 | //GetStatus return the current instance known status 190 | func (i *Instance) GetStatus() *model.Instance { 191 | return i.instance 192 | } 193 | 194 | //Exists check if the instance has been stored 195 | func (i *Instance) Exists() (bool, error) { 196 | 197 | logrus.Debugf("Check if instance %s exists", i.instance.Name) 198 | 199 | dbInstance := new(model.Instance) 200 | err := i.store.Load(i.instance.Name, dbInstance) 201 | if err != nil { 202 | if os.IsNotExist(err) { 203 | return false, nil 204 | } 205 | return false, err 206 | } 207 | 208 | return true, nil 209 | } 210 | 211 | //IsRunning check if the instance is running 212 | func (i *Instance) IsRunning() (bool, error) { 213 | 214 | if i.instance.Status == model.InstanceStarted { 215 | return true, nil 216 | } 217 | 218 | info, err := docker.GetContainer(i.instance.Name) 219 | if err != nil { 220 | return false, err 221 | } 222 | 223 | running := info.ContainerJSONBase != nil 224 | if running { 225 | i.instance.Status = model.InstanceStarted 226 | } else { 227 | i.instance.Status = model.InstanceStopped 228 | } 229 | 230 | return running, nil 231 | } 232 | 233 | //Restart instance 234 | func (i *Instance) Restart() error { 235 | err := i.Stop() 236 | if err != nil { 237 | return fmt.Errorf("Stop: %s", err) 238 | } 239 | return i.Start() 240 | } 241 | 242 | //Reset reset container runtime information 243 | func (i *Instance) Reset() error { 244 | 245 | i.instance.IP = "" 246 | i.instance.Status = model.InstanceStopped 247 | 248 | return nil 249 | } 250 | -------------------------------------------------------------------------------- /api/instance_ip.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/muka/redzilla/docker" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | //GetIP return the container IP 12 | func (i *Instance) GetIP() (string, error) { 13 | 14 | ip := "" 15 | 16 | if i.instance.IP != "" { 17 | return i.instance.IP, nil 18 | } 19 | 20 | net, err := docker.GetNetwork(i.cfg.Network) 21 | if err != nil { 22 | return "", fmt.Errorf("GetNetwork: %s", err) 23 | } 24 | 25 | if len(net.Containers) == 0 { 26 | return "", fmt.Errorf("Network '%s' has no container attached", i.cfg.Network) 27 | } 28 | 29 | for _, container := range net.Containers { 30 | if container.Name == i.instance.Name { 31 | ip = container.IPv4Address[:strings.Index(container.IPv4Address, "/")] 32 | logrus.Debugf("Container IP %s", ip) 33 | break 34 | } 35 | } 36 | 37 | if ip == "" { 38 | return ip, fmt.Errorf("IP not found for container `%s`", i.instance.Name) 39 | } 40 | 41 | i.instance.IP = ip 42 | 43 | return ip, nil 44 | } 45 | -------------------------------------------------------------------------------- /api/instance_log.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/muka/redzilla/docker" 5 | "github.com/sirupsen/logrus" 6 | ) 7 | 8 | //GetLogger Return the dedicated logger 9 | func (i *Instance) GetLogger() *logrus.Logger { 10 | return i.logger.GetLogger() 11 | } 12 | 13 | //StartLogsPipe start the container log pipe 14 | func (i *Instance) StartLogsPipe() error { 15 | logrus.Debugf("Start log pipe for %s", i.instance.Name) 16 | return docker.ContainerWatchLogs(i.logContext.GetContext(), i.instance.Name, i.logger.GetFile()) 17 | } 18 | 19 | //StopLogsPipe stop the container log pipe 20 | func (i *Instance) StopLogsPipe() { 21 | logrus.Debugf("Stopped log pipe for %s", i.instance.Name) 22 | i.logContext.Cancel() 23 | } 24 | -------------------------------------------------------------------------------- /api/instance_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/muka/redzilla/config" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestInstance(t *testing.T) { 13 | 14 | err := config.Init() 15 | assert.NoError(t, err) 16 | 17 | cfg, err := config.GetApiConfig() 18 | assert.NoError(t, err) 19 | 20 | cfg.APIPort = ":50015" 21 | cfg.Network = "redzilla_test" 22 | cfg.StorePath = "../test/data/store" 23 | cfg.InstanceDataPath = "../test/data/instances" 24 | cfg.InstanceConfigPath = "../test/data/config" 25 | 26 | i := NewInstance(fmt.Sprintf("test_%d", time.Now().Unix()), cfg) 27 | 28 | err = i.Start() 29 | assert.NoError(t, err) 30 | 31 | ip, err := i.GetIP() 32 | assert.NoError(t, err) 33 | assert.NotEmpty(t, ip) 34 | 35 | err = i.Stop() 36 | assert.NoError(t, err) 37 | 38 | err = i.Remove() 39 | assert.NoError(t, err) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /api/logger.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var loggerInstances = make(map[string]*InstanceLogger) 12 | 13 | // NewInstanceLogger create a new instance and cache it 14 | func NewInstanceLogger(name string, path string) (*InstanceLogger, error) { 15 | if _, ok := loggerInstances[name]; !ok { 16 | err := createInstanceLogger(name, path) 17 | if err != nil { 18 | return nil, err 19 | } 20 | } 21 | return loggerInstances[name], nil 22 | } 23 | 24 | //CloseInstanceLoggers close all file loggers 25 | func CloseInstanceLoggers() { 26 | for name, instanceLogger := range loggerInstances { 27 | instanceLogger.Close() 28 | delete(loggerInstances, name) 29 | } 30 | } 31 | 32 | //InstanceLogger a logger for a container instance 33 | type InstanceLogger struct { 34 | Name string 35 | Path string 36 | file *os.File 37 | logger *logrus.Logger 38 | } 39 | 40 | //GetLogger return the actual logger 41 | func (i *InstanceLogger) GetLogger() *logrus.Logger { 42 | return i.logger 43 | } 44 | 45 | //GetFile return the file writer 46 | func (i *InstanceLogger) GetFile() io.Writer { 47 | return i.file 48 | } 49 | 50 | //Close close open file loggers 51 | func (i *InstanceLogger) Close() { 52 | i.file.Close() 53 | } 54 | 55 | func createInstanceLogger(name string, path string) error { 56 | 57 | filename := filepath.Join(path, "instance.log") 58 | 59 | logrus.Debugf("Create log for %s at %s", name, path) 60 | 61 | f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // Create a new instance of the logger. You can have any number of instances. 67 | log := logrus.New() 68 | log.Formatter = &logrus.TextFormatter{} 69 | log.Out = f 70 | 71 | li := &InstanceLogger{ 72 | Name: name, 73 | Path: filename, 74 | file: f, 75 | logger: log, 76 | } 77 | 78 | loggerInstances[name] = li 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /api/proxy.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "net/http" 7 | "net/http/httputil" 8 | "strings" 9 | "time" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/gin-gonic/gin" 13 | "github.com/muka/redzilla/model" 14 | ) 15 | 16 | var reverseProxy *httputil.ReverseProxy 17 | 18 | func websocketProxy(target string) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | d, err := net.Dial("tcp", target) 21 | if err != nil { 22 | http.Error(w, "Error contacting backend server.", 500) 23 | logrus.Printf("Error dialing websocket backend %s: %v", target, err) 24 | return 25 | } 26 | hj, ok := w.(http.Hijacker) 27 | if !ok { 28 | http.Error(w, "Not a hijacker?", 500) 29 | return 30 | } 31 | nc, _, err := hj.Hijack() 32 | if err != nil { 33 | logrus.Printf("Hijack error: %v", err) 34 | return 35 | } 36 | defer nc.Close() 37 | defer d.Close() 38 | 39 | err = r.Write(d) 40 | if err != nil { 41 | logrus.Printf("Error copying request to target: %v", err) 42 | return 43 | } 44 | 45 | errc := make(chan error, 2) 46 | cp := func(dst io.Writer, src io.Reader) { 47 | _, err := io.Copy(dst, src) 48 | errc <- err 49 | } 50 | go cp(d, nc) 51 | go cp(nc, d) 52 | <-errc 53 | }) 54 | } 55 | 56 | // newReverseProxy creates a reverse proxy that will redirect request to sub instances 57 | func newReverseProxy(cfg *model.Config) *httputil.ReverseProxy { 58 | director := func(req *http.Request) {} 59 | return &httputil.ReverseProxy{ 60 | Director: director, 61 | Transport: &http.Transport{ 62 | // Proxy: func(req *http.Request) (*url.URL, error) { 63 | // return http.ProxyFromEnvironment(req) 64 | // }, 65 | Dial: func(network, addr string) (net.Conn, error) { 66 | 67 | maxTries := 3 68 | waitFor := time.Millisecond * time.Duration(1000) 69 | 70 | var err error 71 | var conn net.Conn 72 | for tries := 0; tries < maxTries; tries++ { 73 | 74 | conn, err = (&net.Dialer{ 75 | Timeout: 30 * time.Second, 76 | KeepAlive: 30 * time.Second, 77 | }).Dial(network, addr) 78 | 79 | if err != nil { 80 | logrus.Warnf("Dial failed, retrying (%s)", err.Error()) 81 | time.Sleep(waitFor) 82 | continue 83 | } 84 | 85 | break 86 | } 87 | return conn, err 88 | }, 89 | // TLSHandshakeTimeout: 10 * time.Second, 90 | }, 91 | } 92 | } 93 | 94 | func isWebsocket(req *http.Request) bool { 95 | connHeader := "" 96 | connHeaders := req.Header["Connection"] 97 | if len(connHeaders) > 0 { 98 | connHeader = connHeaders[0] 99 | } 100 | 101 | upgradeWebsocket := false 102 | if strings.ToLower(connHeader) == "upgrade" { 103 | upgradeHeaders := req.Header["Upgrade"] 104 | if len(upgradeHeaders) > 0 { 105 | upgradeWebsocket = (strings.ToLower(upgradeHeaders[0]) == "websocket") 106 | } 107 | } 108 | 109 | return upgradeWebsocket 110 | } 111 | 112 | //Handler for proxyed router requests 113 | func proxyHandler(cfg *model.Config) func(c *gin.Context) { 114 | reverseProxy = newReverseProxy(cfg) 115 | return func(c *gin.Context) { 116 | 117 | if !isSubdomain(c.Request.Host, cfg.Domain) || isRootDomain(c.Request.Host, cfg.Domain) { 118 | c.Next() 119 | return 120 | } 121 | 122 | name := extractSubdomain(c.Request.Host, cfg) 123 | if len(name) == 0 { 124 | logrus.Debugf("Empty subdomain name at %s", c.Request.URL.String()) 125 | notFound(c) 126 | return 127 | } 128 | 129 | // logrus.Debugf("Proxying %s name=%s ", c.Request.URL, name) 130 | 131 | instance := GetInstance(name, cfg) 132 | if instance == nil { 133 | notFound(c) 134 | return 135 | } 136 | 137 | running, err := instance.IsRunning() 138 | if err != nil { 139 | internalError(c, err) 140 | return 141 | } 142 | 143 | if !running { 144 | logrus.Debugf("Container %s not running", name) 145 | if cfg.Autostart { 146 | logrus.Debugf("Starting stopped container %s", name) 147 | serr := instance.Start() 148 | if serr != nil { 149 | internalError(c, serr) 150 | return 151 | } 152 | } else { 153 | badRequest(c) 154 | return 155 | } 156 | } 157 | 158 | ip, err := instance.GetIP() 159 | if err != nil { 160 | internalError(c, err) 161 | return 162 | } 163 | 164 | c.Request.Host = ip + ":" + NodeRedPort 165 | 166 | c.Request.URL.Scheme = "http" 167 | c.Request.URL.Host = c.Request.Host 168 | 169 | if isWebsocket(c.Request) { 170 | wsURL := c.Request.URL.Hostname() + ":" + c.Request.URL.Port() 171 | logrus.Debugf("Serving WS %s", wsURL) 172 | p := websocketProxy(wsURL) 173 | p.ServeHTTP(c.Writer, c.Request) 174 | return 175 | } 176 | 177 | reverseProxy.ServeHTTP(c.Writer, c.Request) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/muka/redzilla/model" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // JSONError a JSON response in case of error 15 | type JSONError struct { 16 | Code int `json:"code"` 17 | Message string `json:"message"` 18 | } 19 | 20 | // errorResponse send an error response with a common JSON message 21 | func errorResponse(c *gin.Context, code int, message string) { 22 | c.JSON(code, JSONError{code, message}) 23 | } 24 | 25 | func internalError(c *gin.Context, err error) { 26 | logrus.Errorf("Internal Error: %s", err.Error()) 27 | logrus.Debugf("%+v", err) 28 | code := http.StatusInternalServerError 29 | errorResponse(c, code, http.StatusText(code)) 30 | } 31 | 32 | func notFound(c *gin.Context) { 33 | code := http.StatusNotFound 34 | errorResponse(c, code, http.StatusText(code)) 35 | } 36 | 37 | func badRequest(c *gin.Context) { 38 | code := http.StatusNotFound 39 | errorResponse(c, code, http.StatusText(code)) 40 | } 41 | 42 | func extractSubdomain(host string, cfg *model.Config) string { 43 | if len(host) == 0 { 44 | return "" 45 | } 46 | hostname := host[:strings.Index(host, ":")] 47 | name := strings.Replace(hostname, "."+cfg.Domain, "", -1) 48 | return name 49 | } 50 | 51 | func instanceExists(c *gin.Context, instance *Instance) bool { 52 | 53 | exists, err := instance.Exists() 54 | if err != nil { 55 | internalError(c, err) 56 | return false 57 | } 58 | 59 | if !exists { 60 | notFound(c) 61 | return false 62 | } 63 | 64 | return true 65 | } 66 | 67 | func isSubdomain(host, domain string) bool { 68 | portIdx := strings.Index(host, ":") 69 | if portIdx > -1 { 70 | host = host[0:portIdx] 71 | } 72 | // handle only on main domain 73 | subdIndex := strings.Index(host, ".") 74 | if subdIndex > -1 { 75 | return host[subdIndex+1:] == domain 76 | } 77 | return false 78 | } 79 | 80 | func isRootDomain(host, domain string) bool { 81 | portIdx := strings.Index(host, ":") 82 | if portIdx > -1 { 83 | host = host[0:portIdx] 84 | } 85 | // handle only on main domain 86 | return host == domain 87 | } 88 | 89 | func validateName(name string) (string, error) { 90 | re := regexp.MustCompile("[^0-9a-z_-]") 91 | if len(re.FindStringSubmatch(name)) > 0 { 92 | return "", errors.New("Invalid instance name") 93 | } 94 | return name, nil 95 | } 96 | -------------------------------------------------------------------------------- /api/util_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsRootDomain(t *testing.T) { 8 | const testDomain = "example.localhost" 9 | const testSubDomain = "foobar." + testDomain 10 | if !isRootDomain(testDomain, testDomain) { 11 | t.Fail() 12 | } 13 | if isRootDomain(testSubDomain, testDomain) { 14 | t.Fail() 15 | } 16 | } 17 | 18 | func TestIsSubdomain(t *testing.T) { 19 | const testDomain = "example.localhost" 20 | const testSubDomain = "foobar." + testDomain 21 | if isSubdomain(testDomain, testDomain) { 22 | t.Fail() 23 | } 24 | if !isSubdomain(testSubDomain, testDomain) { 25 | t.Fail() 26 | } 27 | } 28 | 29 | func TestValidateName(t *testing.T) { 30 | const testSubDomain = "foobar" 31 | const testInvalidSubDomain = "foobar.juju" 32 | var err error 33 | _, err = validateName(testSubDomain) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | _, err = validateName(testInvalidSubDomain) 38 | if err == nil { 39 | t.Fatal(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | 8 | httpclient "github.com/ddliu/go-httpclient" 9 | ) 10 | 11 | func NewClient(opts ClientOptions) (*Client, error) { 12 | 13 | c := new(Client) 14 | c.options = opts 15 | 16 | m := httpclient.Map{ 17 | httpclient.OPT_USERAGENT: "go-nodered-client", 18 | "Node-RED-API-Version": "v2", 19 | } 20 | 21 | if opts.Authorization != "" { 22 | m["Authorization"] = "Bearer " + opts.Authorization 23 | } 24 | 25 | c.client = httpclient.NewHttpClient().Defaults(m) 26 | return c, nil 27 | } 28 | 29 | type ClientOptions struct { 30 | BaseUrl string 31 | Authorization string 32 | } 33 | 34 | type Client struct { 35 | options ClientOptions 36 | client *httpclient.HttpClient 37 | } 38 | 39 | func (c *Client) req(method, path string, body []byte) ([]byte, error) { 40 | return c.reqWithHeaders(method, path, body, map[string]string{}) 41 | } 42 | 43 | func (c *Client) reqWithHeaders(method, path string, body []byte, addonHeaders map[string]string) ([]byte, error) { 44 | 45 | url := c.options.BaseUrl + path 46 | 47 | headers := map[string]string{ 48 | "Content-type": "application/json", 49 | } 50 | if len(addonHeaders) > 0 { 51 | for k, v := range addonHeaders { 52 | headers[k] = v 53 | } 54 | } 55 | 56 | r, err := c.client.Begin().Do(method, url, headers, bytes.NewReader(body)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if r.StatusCode >= 400 { 61 | return nil, fmt.Errorf("Request failed with %s", r.Status) 62 | } 63 | res, err := ioutil.ReadAll(r.Body) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return res, nil 68 | } 69 | -------------------------------------------------------------------------------- /client/client_auth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // AuthLoginGet Get the active authentication scheme 12 | func (c *Client) AuthLoginGet() (*AuthScheme, error) { 13 | res, err := c.req("GET", "/auth/login", nil) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | r := new(AuthScheme) 19 | err = json.Unmarshal(res, r) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return r, nil 25 | } 26 | 27 | // AuthToken Exchange credentials for access token 28 | func (c *Client) AuthToken(authToken AuthTokenRequest) (*AuthToken, error) { 29 | 30 | body, err := json.Marshal(authToken) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | res, err := c.req("POST", "/auth/token", body) 36 | logrus.Debug() 37 | 38 | // Error - Cannot POST /auth/token 39 | if strings.Contains(string(res), "Error") { 40 | return nil, errors.New("Request failed") 41 | } 42 | 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | r := new(AuthToken) 48 | err = json.Unmarshal(res, r) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return r, nil 54 | } 55 | 56 | // AuthTokenRevoke Revoke an access token 57 | func (c *Client) AuthTokenRevoke(token string) error { 58 | r := new(AuthRevoke) 59 | r.Token = token 60 | b, err := json.Marshal(r) 61 | if err != nil { 62 | return err 63 | } 64 | _, err = c.req("POST", "/auth/revoke", b) 65 | if err != nil { 66 | return err 67 | } 68 | return nil 69 | } 70 | 71 | // Settings Get the runtime settings 72 | func (c *Client) Settings() (*Settings, error) { 73 | res, err := c.req("GET", "/settings", nil) 74 | if err != nil { 75 | return nil, err 76 | } 77 | r := new(Settings) 78 | err = json.Unmarshal(res, r) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return r, err 83 | } 84 | -------------------------------------------------------------------------------- /client/client_flow.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | ) 8 | 9 | // FlowsList Get the active flow configuration 10 | func (c *Client) FlowsList() (*FlowsList, error) { 11 | res, err := c.req("GET", "/flows", nil) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | list := new(FlowsList) 17 | err = json.Unmarshal(res, list) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return list, nil 23 | } 24 | 25 | func (c *Client) FlowsSet(flows FlowsList) (*FlowsRev, error) { 26 | return c.FlowsSetWithType(FlowsDeploymentTypeDefault, flows) 27 | } 28 | 29 | // FlowsSet Set the active flow configuration 30 | func (c *Client) FlowsSetWithType(deploymentType FlowsDeploymentType, flows FlowsList) (*FlowsRev, error) { 31 | 32 | b, err := json.Marshal(flows) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | res, err := c.reqWithHeaders("POST", "/flows", b, map[string]string{ 38 | "Node-RED-Deployment-Type": string(deploymentType), 39 | }) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | rev := new(FlowsRev) 45 | err = json.Unmarshal(res, rev) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return rev, nil 51 | } 52 | 53 | // FlowsAdd Add a flow to the active configuration 54 | func (c *Client) FlowAdd(flow *FlowConfig) (*Flow, error) { 55 | 56 | b, err := json.Marshal(flow) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | res, err := c.req("POST", "/flow", b) 62 | 63 | r := new(Flow) 64 | err = json.Unmarshal(res, r) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return r, nil 70 | } 71 | 72 | // FlowsGet Get an individual flow configuration 73 | func (c *Client) FlowGet(id string) (*FlowConfig, error) { 74 | b, err := c.req("GET", fmt.Sprintf("/flow/%s", id), nil) 75 | if err != nil { 76 | return nil, err 77 | } 78 | r := new(FlowConfig) 79 | err = json.Unmarshal(b, r) 80 | if err != nil { 81 | return nil, err 82 | } 83 | return r, nil 84 | } 85 | 86 | // FlowGetGlobal return the global flow config 87 | func (c *Client) FlowGetGlobal() (*FlowConfig, error) { 88 | return c.FlowGet("global") 89 | } 90 | 91 | // UpdateFlow Update an individual flow configuration 92 | func (c *Client) FlowUpdate(flow *FlowConfig) (*Flow, error) { 93 | 94 | if flow.Id == "" { 95 | return nil, errors.New("Id must be provided") 96 | } 97 | 98 | d, err := json.Marshal(flow) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | b, err := c.req("PUT", fmt.Sprintf("/flow/%s", flow.Id), d) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | r := new(Flow) 109 | err = json.Unmarshal(b, r) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | return r, nil 115 | } 116 | 117 | // DeleteFlow Delete an individual flow configuration 118 | func (c *Client) FlowsDelete(id string) error { 119 | _, err := c.req("DELETE", fmt.Sprintf("/flow/%s", id), nil) 120 | return err 121 | } 122 | -------------------------------------------------------------------------------- /client/client_nodes.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // NodesList Get a list of the installed nodes 9 | func (c *Client) NodesList() ([]NodeSet, error) { 10 | 11 | b, err := c.reqWithHeaders("GET", "/nodes", nil, map[string]string{"Accept": "application/json"}) 12 | 13 | r := []NodeSet{} 14 | err = json.Unmarshal(b, &r) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return r, nil 20 | } 21 | 22 | // NodesAdd Install a new node module 23 | func (c *Client) NodesAdd(module string) (*NodeModule, error) { 24 | 25 | n := NodeInstall{Module: module} 26 | d, err := json.Marshal(&n) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | b, err := c.req("POST", "/nodes", d) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | i := new(NodeModule) 37 | err = json.Unmarshal(b, i) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return i, nil 43 | } 44 | 45 | // NodesGet Get a node module’s information 46 | func (c *Client) NodesGet(module string) (*NodeModule, error) { 47 | b, err := c.req("Get", fmt.Sprintf("/nodes/%s", module), nil) 48 | if err != nil { 49 | return nil, err 50 | } 51 | r := new(NodeModule) 52 | err = json.Unmarshal(b, r) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return r, nil 57 | } 58 | 59 | // Enable/Disable a node module 60 | func (c *Client) NodesToggle(module string) (*NodeToggle, error) { 61 | b, err := c.req("PUT", fmt.Sprintf("/nodes/%s", module), nil) 62 | if err != nil { 63 | return nil, err 64 | } 65 | r := new(NodeToggle) 66 | err = json.Unmarshal(b, r) 67 | if err != nil { 68 | return nil, err 69 | } 70 | return r, nil 71 | } 72 | 73 | // NodesRemove Remove a node module 74 | func (c *Client) NodesRemove(module string) error { 75 | _, err := c.req("DELETE", fmt.Sprintf("/nodes/%s", module), nil) 76 | if err != nil { 77 | return err 78 | } 79 | return nil 80 | } 81 | 82 | // NodesModuleSetGet Get a node module set information 83 | func (c *Client) NodesModuleSetGet(module, set string) (*NodeSet, error) { 84 | b, err := c.req("GET", fmt.Sprintf("/nodes/%s/%s", module, set), nil) 85 | if err != nil { 86 | return nil, err 87 | } 88 | n := new(NodeSet) 89 | err = json.Unmarshal(b, n) 90 | if err != nil { 91 | return nil, err 92 | } 93 | return n, nil 94 | } 95 | 96 | // NodesModuleSetToggle Enable/Disable a node set 97 | func (c *Client) NodesModuleSetToggle(module, set string) (*NodeToggle, error) { 98 | b, err := c.req("PUT", fmt.Sprintf("/nodes/%s/%s", module, set), nil) 99 | if err != nil { 100 | return nil, err 101 | } 102 | r := new(NodeToggle) 103 | err = json.Unmarshal(b, r) 104 | if err != nil { 105 | return nil, err 106 | } 107 | return r, nil 108 | } 109 | -------------------------------------------------------------------------------- /client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/docker/docker/api/types" 8 | "github.com/muka/redzilla/docker" 9 | "github.com/muka/redzilla/model" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func startContainer(t *testing.T) (*types.ContainerJSON, func()) { 14 | 15 | logrus.SetLevel(logrus.DebugLevel) 16 | 17 | containerName := "container_redz_test_api" 18 | cfg := &model.Config{ 19 | Network: "redzilla_test", 20 | ImageName: "nodered/node-red-docker:latest", 21 | InstanceConfigPath: "../data/test", 22 | InstanceDataPath: "../data/test", 23 | Autostart: true, 24 | } 25 | 26 | err := docker.StartContainer(containerName, cfg) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | info, err := docker.GetContainer(containerName) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | return info, func() { 37 | err = docker.StopContainer(containerName, true) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | } 42 | 43 | } 44 | 45 | func TestClientMethods(t *testing.T) { 46 | 47 | info, _ := startContainer(t) 48 | 49 | // info, stopContainer := startContainer(t) 50 | // defer stopContainer() 51 | 52 | ip := info.NetworkSettings.Networks["redzilla_test"].IPAddress 53 | 54 | c, err := NewClient(ClientOptions{ 55 | BaseUrl: fmt.Sprintf("http://%s:1880", ip), 56 | }) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | authScheme, err := c.AuthLoginGet() 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | logrus.Debugf("AuthLoginGet: %++v", authScheme) 66 | 67 | if authScheme.IsActive() { 68 | t.Fatal("AuthScheme should not be enabled") 69 | } 70 | 71 | // Review this method 72 | _, err = c.AuthToken(NewAuthTokenRequest()) 73 | if err != nil { 74 | // t.Fatal(err) 75 | logrus.Debugf("AuthToken err: %++v", err) 76 | } 77 | // logrus.Debugf("AuthToken: %++v", authToken) 78 | 79 | if authScheme.IsActive() { 80 | t.Fatal("AuthScheme should not be enabled") 81 | } 82 | 83 | list, err := c.FlowsList() 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | logrus.Debugf("FlowsList: %++v", list) 88 | 89 | } 90 | -------------------------------------------------------------------------------- /client/types_auth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | // https://nodered.org/docs/api/admin/methods/get/auth/login/ 4 | 5 | type Prompt struct { 6 | Id string 7 | Type string 8 | Value string 9 | } 10 | 11 | type AuthScheme struct { 12 | Type string 13 | Prompts []Prompt 14 | } 15 | 16 | type AuthRevoke struct { 17 | Token string 18 | } 19 | 20 | // IsActive check if there is an auth scheme enabled 21 | func (a *AuthScheme) IsActive() bool { 22 | return len(a.Type) > 0 23 | } 24 | 25 | // https://nodered.org/docs/api/admin/methods/post/auth/token/ 26 | 27 | type AuthTokenRequest struct { 28 | ClientId string `json:"client_id"` 29 | GrantType string `json:"grant_type"` 30 | Scope string 31 | Username string 32 | Password string 33 | } 34 | 35 | type AuthToken struct { 36 | AccessToken string `json:"access_token"` 37 | ExpiresIn string `json:"expires_in"` 38 | TokenType string `json:"token_type"` 39 | } 40 | 41 | func NewAuthTokenRequest() AuthTokenRequest { 42 | return AuthTokenRequest{ 43 | ClientId: "node-red-admin", 44 | GrantType: "password", 45 | Scope: "*", 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/types_flows.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | type FlowsDeploymentType string 4 | 5 | const ( 6 | FlowsDeploymentTypeFull FlowsDeploymentType = "full" 7 | FlowsDeploymentTypeNodes FlowsDeploymentType = "nodes" 8 | FlowsDeploymentTypeFlows FlowsDeploymentType = "flows" 9 | FlowsDeploymentTypeReload FlowsDeploymentType = "reload" 10 | FlowsDeploymentTypeDefault FlowsDeploymentType = FlowsDeploymentTypeFull 11 | ) 12 | 13 | type FlowConfig struct { 14 | Id string `json:"id,omitempty"` 15 | Label string `json:"label,omitempty"` 16 | Nodes []string 17 | Configs []string `json:"configs,omitempty"` 18 | Subflows []FlowConfig `json:"subflows,omitempty"` 19 | } 20 | 21 | type Flow struct { 22 | Type string `json:"type,omitempty"` 23 | Id string `json:"id"` 24 | Label string `json:"label,omitempty"` 25 | } 26 | 27 | type FlowsList struct { 28 | Rev string 29 | Flows []Flow 30 | } 31 | 32 | type FlowsRev struct { 33 | Rev string 34 | } 35 | -------------------------------------------------------------------------------- /client/types_nodes.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | // https://nodered.org/docs/api/admin/methods/get/nodes/ 4 | 5 | type NodeInstall struct { 6 | Module string 7 | } 8 | 9 | type NodeToggle struct { 10 | Enabled bool 11 | } 12 | 13 | type NodeSet struct { 14 | Id string 15 | Name string 16 | Types []string 17 | Enabled bool 18 | Module string 19 | Version string 20 | } 21 | 22 | type NodeModule struct { 23 | Name string 24 | Version string 25 | Nodes []NodeSet 26 | } 27 | -------------------------------------------------------------------------------- /client/types_settings.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | // https://nodered.org/docs/api/admin/methods/get/settings/ 4 | 5 | type SettingsUser struct { 6 | Username string 7 | Permissions string 8 | } 9 | 10 | type Settings struct { 11 | HttpNodeRoot string `json:"http_node_root"` 12 | Version string `json:"version"` 13 | User SettingsUser `json:"user"` 14 | } 15 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | 2 | Network: redzilla 3 | APIPort: :3000 4 | 5 | Domain: redzilla.localhost 6 | 7 | ImageName: docker.io/nodered/node-red-docker:latest 8 | StorePath: ./data/store 9 | 10 | # Mounted to /data, will be ${InstanceDataPath}/${InstanceName} with instance name in path 11 | InstanceDataPath: ./data/instances 12 | 13 | # Mounted to /config, will be ${InstanceConfigPath} with no instance name specialization 14 | InstanceConfigPath: ./data/config 15 | 16 | LogLevel: info 17 | 18 | EnvPrefix: 19 | 20 | # none or http 21 | AuthType: none 22 | 23 | # HTTP based auth / ACL will performa a POST request to an endpoint and allow on 2xx or deny on other responses 24 | # Body is a go template 25 | AuthHttpMethod: POST 26 | AuthHttpUrl: http://localhost/auth/check 27 | AuthHttpHeader: Authorization 28 | AuthHttpBody: "{ \"name\": \"{{.Name}}\", \"url\": \"{{.Url}}\", \"method\": \"{{.Method}}\" }" 29 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "os" 7 | "strings" 8 | 9 | "github.com/muka/redzilla/model" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func Init() error { 14 | 15 | viper.SetDefault("Network", "redzilla") 16 | viper.SetDefault("APIPort", ":3000") 17 | viper.SetDefault("Domain", "redzilla.localhost") 18 | viper.SetDefault("ImageName", "nodered/node-red-docker") 19 | viper.SetDefault("StorePath", "./data/store") 20 | viper.SetDefault("InstanceDataPath", "./data/instances") 21 | viper.SetDefault("InstanceConfigPath", "./data/config") 22 | viper.SetDefault("LogLevel", "info") 23 | viper.SetDefault("Autostart", false) 24 | viper.SetDefault("EnvPrefix", "") 25 | 26 | viper.SetDefault("AuthType", "none") 27 | viper.SetDefault("AuthHttpMethod", "GET") 28 | viper.SetDefault("AuthHttpUrl", "") 29 | viper.SetDefault("AuthHttpHeader", "Authorization") 30 | 31 | viper.SetEnvPrefix("redzilla") 32 | viper.AutomaticEnv() 33 | 34 | configFile := "./config.yml" 35 | if os.Getenv("REDZILLA_CONFIG") != "" { 36 | configFile = os.Getenv("REDZILLA_CONFIG") 37 | } 38 | 39 | if _, err := os.Stat(configFile); !os.IsNotExist(err) { 40 | viper.SetConfigFile(configFile) 41 | err := viper.ReadInConfig() 42 | if err != nil { 43 | return fmt.Errorf("Failed to read from config file: %s", err) 44 | } 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func GetApiConfig() (*model.Config, error) { 51 | 52 | cfg := &model.Config{ 53 | Network: viper.GetString("Network"), 54 | APIPort: viper.GetString("APIPort"), 55 | Domain: viper.GetString("Domain"), 56 | ImageName: viper.GetString("ImageName"), 57 | StorePath: viper.GetString("StorePath"), 58 | InstanceDataPath: viper.GetString("InstanceDataPath"), 59 | InstanceConfigPath: viper.GetString("InstanceConfigPath"), 60 | LogLevel: viper.GetString("LogLevel"), 61 | Autostart: viper.GetBool("Autostart"), 62 | EnvPrefix: viper.GetString("EnvPrefix"), 63 | AuthType: viper.GetString("AuthType"), 64 | } 65 | 66 | if strings.ToLower(cfg.AuthType) == "http" { 67 | 68 | a := new(model.AuthHttp) 69 | a.Method = viper.GetString("AuthHttpMethod") 70 | a.URL = viper.GetString("AuthHttpUrl") 71 | a.Header = viper.GetString("AuthHttpHeader") 72 | 73 | //setup the body template 74 | rawTpl := viper.GetString("AuthHttpHeader") 75 | if len(rawTpl) > 0 { 76 | bodyTemplate, err := template.New("").Parse(rawTpl) 77 | if err != nil { 78 | return nil, fmt.Errorf("Failed to parse template: %s", err) 79 | } 80 | a.Body = bodyTemplate 81 | } 82 | 83 | cfg.AuthHttp = a 84 | } 85 | 86 | return cfg, nil 87 | } 88 | -------------------------------------------------------------------------------- /data/instances/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muka/redzilla/c8d3c780b24f6adf60a09190337051e7d9713673/data/instances/.gitkeep -------------------------------------------------------------------------------- /data/store/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muka/redzilla/c8d3c780b24f6adf60a09190337051e7d9713673/data/store/.gitkeep -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redzilla: 5 | image: opny/redzilla 6 | environment: 7 | DOCKER_API_VERSION: 1.39 8 | # compose created network name 9 | REDZILLA_NETWORK: redzilla_redzilla 10 | REDZILLA_DOMAIN: redzilla.localhost 11 | REDZILLA_IMAGENAME: docker.io/nodered/node-red-docker:latest 12 | networks: 13 | - redzilla 14 | ports: 15 | - 3000:3000 16 | volumes: 17 | - /var/run/docker.sock:/var/run/docker.sock 18 | - ./data:/data 19 | 20 | networks: 21 | redzilla: 22 | driver: bridge 23 | -------------------------------------------------------------------------------- /docker/client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/docker/docker/api/types" 9 | "github.com/docker/docker/client" 10 | "github.com/muka/redzilla/model" 11 | "github.com/spf13/viper" 12 | 13 | "golang.org/x/net/context" 14 | ) 15 | 16 | const DefaultDockerApiVersion = "1.39" 17 | 18 | var dockerClient *client.Client 19 | 20 | //return a docker client 21 | func getClient() (*client.Client, error) { 22 | 23 | version := viper.GetString("docker_api_version") 24 | if version == "" { 25 | version = DefaultDockerApiVersion 26 | } 27 | 28 | os.Setenv("DOCKER_API_VERSION", version) 29 | 30 | if dockerClient == nil { 31 | cli, err := client.NewEnvClient() 32 | if err != nil { 33 | return nil, err 34 | } 35 | dockerClient = cli 36 | } 37 | 38 | return dockerClient, nil 39 | } 40 | 41 | func extractEnv(cfg *model.Config) []string { 42 | 43 | env := make([]string, 0) 44 | 45 | vars := os.Environ() 46 | 47 | envPrefix := strings.ToLower(cfg.EnvPrefix) 48 | pl := len(envPrefix) 49 | 50 | if pl > 0 { 51 | for _, e := range vars { 52 | 53 | if pl > 0 { 54 | if pl > len(e) { 55 | continue 56 | } 57 | if strings.ToLower(e[0:pl]) != envPrefix { 58 | continue 59 | } 60 | } 61 | 62 | //removed PREFIX_ 63 | envVar := e[pl+1:] 64 | env = append(env, envVar) 65 | } 66 | 67 | } 68 | 69 | return env 70 | } 71 | 72 | // GetContainer return container info by name 73 | func GetContainer(name string) (*types.ContainerJSON, error) { 74 | 75 | ctx := context.Background() 76 | emptyJSON := &types.ContainerJSON{} 77 | 78 | if len(name) == 0 { 79 | return emptyJSON, errors.New("GetContainer(): name is empty") 80 | } 81 | 82 | cli, err := getClient() 83 | if err != nil { 84 | return emptyJSON, err 85 | } 86 | 87 | json, err := cli.ContainerInspect(ctx, name) 88 | if err != nil { 89 | if client.IsErrNotFound(err) { 90 | return emptyJSON, nil 91 | } 92 | return emptyJSON, err 93 | } 94 | 95 | return &json, nil 96 | } 97 | -------------------------------------------------------------------------------- /docker/events.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "github.com/docker/docker/api/types" 5 | "github.com/docker/docker/api/types/events" 6 | "github.com/docker/docker/api/types/filters" 7 | "github.com/muka/redzilla/model" 8 | "github.com/sirupsen/logrus" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | //ContainerEvent store a container event 13 | type ContainerEvent struct { 14 | ID string 15 | Name string 16 | Action string 17 | Message events.Message 18 | } 19 | 20 | var eventsChannel = make(chan *ContainerEvent) 21 | 22 | //GetEventsChannel return the main channel reporting docker events 23 | func GetEventsChannel() chan *ContainerEvent { 24 | return eventsChannel 25 | } 26 | 27 | // ListenEvents watches docker events an handle state modifications 28 | func ListenEvents(cfg *model.Config) chan *ContainerEvent { 29 | 30 | cli, err := getClient() 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | ctx := context.Background() 36 | // ctx1 := context.Background() 37 | // ctx, cancel := context.WithCancel(ctx1) 38 | 39 | f := filters.NewArgs() 40 | f.Add("label", "redzilla=1") 41 | // <-chan events.Message, <-chan error 42 | msgChan, errChan := cli.Events(ctx, types.EventsOptions{ 43 | Filters: f, 44 | }) 45 | 46 | go func() { 47 | for { 48 | select { 49 | case event := <-msgChan: 50 | if &event != nil { 51 | 52 | logrus.Infof("Event recieved: %s %s ", event.Action, event.Type) 53 | if event.Actor.Attributes != nil { 54 | 55 | // logrus.Infof("%s: %s | %s | %s | %s | %s", event.Actor.Attributes["name"], event.Action, event.From, event.ID, event.Status, event.Type) 56 | 57 | name := event.Actor.Attributes["name"] 58 | switch event.Action { 59 | case "start": 60 | logrus.Debugf("Container started %s", name) 61 | break 62 | case "die": 63 | logrus.Debugf("Container exited %s", name) 64 | break 65 | } 66 | 67 | ev := &ContainerEvent{ 68 | Action: event.Action, 69 | ID: event.ID, 70 | Name: name, 71 | Message: event, 72 | } 73 | eventsChannel <- ev 74 | 75 | } 76 | } 77 | case err := <-errChan: 78 | if err != nil { 79 | logrus.Errorf("Error event recieved: %s", err.Error()) 80 | } 81 | } 82 | } 83 | }() 84 | 85 | return eventsChannel 86 | } 87 | -------------------------------------------------------------------------------- /docker/images.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/api/types/filters" 12 | "github.com/docker/docker/pkg/archive" 13 | "github.com/sirupsen/logrus" 14 | "golang.org/x/net/context" 15 | ) 16 | 17 | type buildImageLog struct { 18 | Stream string `json:"stream,omitempty"` 19 | ErrorDetail string `json:"errorDetail,omitempty"` 20 | } 21 | 22 | func EnsureImage(imageName string) error { 23 | 24 | if imageName == "" { 25 | return errors.New("Image name cannot be empty") 26 | } 27 | 28 | cli, err := getClient() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | ctx := context.Background() 34 | 35 | f := filters.NewArgs() 36 | f.ExactMatch("Name", imageName) 37 | localImages, err := cli.ImageList(ctx, types.ImageListOptions{ 38 | Filters: f, 39 | }) 40 | 41 | if len(localImages) > 0 { 42 | for _, image := range localImages { 43 | for _, tag := range image.RepoTags { 44 | // fmt.Println(tag) 45 | if imageName == tag || imageName+":latest" == tag { 46 | logrus.Debugf("Found local image %s", imageName) 47 | return nil 48 | } 49 | } 50 | } 51 | } 52 | 53 | logrus.Debugf("Pulling image %s if not available", imageName) 54 | _, err = cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) 55 | if err != nil { 56 | return fmt.Errorf("Image pull failed: %s", err) 57 | } 58 | 59 | logrus.Debugf("Pulled image %s", imageName) 60 | return nil 61 | } 62 | 63 | func RemoveImage(imageName string, force bool) error { 64 | 65 | cli, err := getClient() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | ctx := context.Background() 71 | 72 | f := filters.NewArgs() 73 | f.ExactMatch("Name", imageName) 74 | localImages, err := cli.ImageList(ctx, types.ImageListOptions{ 75 | Filters: f, 76 | }) 77 | 78 | imageID := "" 79 | if len(localImages) > 0 { 80 | for _, image := range localImages { 81 | for _, tag := range image.RepoTags { 82 | // fmt.Println(tag) 83 | if imageName == tag || imageName+":latest" == tag { 84 | logrus.Debugf("Removing image %s (%s)", imageName, image.ID) 85 | imageID = image.ID 86 | break 87 | } 88 | } 89 | if imageID != "" { 90 | break 91 | } 92 | } 93 | } 94 | 95 | if imageID == "" { 96 | return fmt.Errorf("RemoveImage: Cannot find image %s", imageName) 97 | } 98 | 99 | _, err = cli.ImageRemove(context.Background(), imageID, types.ImageRemoveOptions{ 100 | Force: force, 101 | }) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func BuildImage(imageName, srcImageDir string) error { 110 | 111 | cli, err := getClient() 112 | if err != nil { 113 | return err 114 | } 115 | 116 | buildContext, err := archive.TarWithOptions(srcImageDir, &archive.TarOptions{}) 117 | if err != nil { 118 | return fmt.Errorf("Tar failed: %s", err) 119 | } 120 | 121 | res, err := cli.ImageBuild(context.Background(), buildContext, types.ImageBuildOptions{ 122 | Tags: []string{imageName + ":latest"}, 123 | SuppressOutput: false, 124 | }) 125 | if err != nil { 126 | return fmt.Errorf("ImageBuild error: %s", err) 127 | } 128 | 129 | scanner := bufio.NewScanner(res.Body) 130 | 131 | for scanner.Scan() { 132 | 133 | // logrus.Debug(scanner.Text()) 134 | 135 | line := new(buildImageLog) 136 | err := json.Unmarshal(scanner.Bytes(), line) 137 | if err != nil { 138 | return fmt.Errorf("Failed to parse log: %s", err) 139 | } 140 | 141 | if line.ErrorDetail != "" { 142 | logLine := strings.Replace(line.ErrorDetail, "\n", "", -1) 143 | logrus.Warnf("build log: %s", logLine) 144 | return fmt.Errorf("ImageBuild failed: %s", logLine) 145 | } 146 | 147 | logLine := strings.Replace(line.Stream, "\n", "", -1) 148 | logrus.Debugf("build log: %s", logLine) 149 | } 150 | 151 | logrus.Debugf("Created image %s", imageName) 152 | 153 | return nil 154 | } 155 | -------------------------------------------------------------------------------- /docker/images_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func TestEnsureRemoteImage(t *testing.T) { 10 | 11 | logrus.SetLevel(logrus.DebugLevel) 12 | 13 | imageName := "docker.io/nodered/node-red-docker:latest" 14 | 15 | err := EnsureImage(imageName) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | } 21 | 22 | func TestEnsureLocalImage(t *testing.T) { 23 | 24 | logrus.SetLevel(logrus.DebugLevel) 25 | 26 | imageName := "test_local_image_build" 27 | err := BuildImage(imageName, "../test") 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | err = EnsureImage(imageName) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | err = RemoveImage(imageName, true) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | defer RemoveImage(imageName, true) 43 | 44 | } 45 | 46 | func TestBuildImageFail(t *testing.T) { 47 | 48 | logrus.SetLevel(logrus.DebugLevel) 49 | 50 | imageName := "test_local_image_build" 51 | err := BuildImage(imageName, "../test/build_fail") 52 | if err == nil { 53 | t.Fatal("Build shoul fail") 54 | } 55 | 56 | defer RemoveImage(imageName, true) 57 | 58 | } 59 | -------------------------------------------------------------------------------- /docker/logs.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/docker/docker/api/types" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | // ContainerWatchLogs pipe logs from the container instance 14 | func ContainerWatchLogs(ctx context.Context, name string, writer io.Writer) error { 15 | 16 | cli, err := getClient() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | info, err := GetContainer(name) 22 | if err != nil { 23 | return err 24 | } 25 | if info.ContainerJSONBase == nil { 26 | return errors.New("Container not found " + name) 27 | } 28 | 29 | containerID := info.ContainerJSONBase.ID 30 | 31 | out, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ 32 | ShowStderr: true, 33 | ShowStdout: true, 34 | Follow: true, 35 | }) 36 | 37 | if err != nil { 38 | logrus.Warnf("Failed to open logs %s: %s", name, err.Error()) 39 | return err 40 | } 41 | 42 | // if logrus.GetLevel() == logrus.DebugLevel { 43 | go func() { 44 | logrus.Debug("Printing instances log") 45 | buf := bufio.NewScanner(out) 46 | for buf.Scan() { 47 | logrus.Debugf("%s", buf.Text()) 48 | } 49 | }() 50 | // } 51 | 52 | go func() { 53 | // pipe stream, will stop when container stops 54 | if _, err := io.Copy(writer, out); err != nil { 55 | logrus.Warnf("Error copying log stream %s", name) 56 | } 57 | }() 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /docker/network.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/filters" 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | //GetNetwork inspect a network by networkID 12 | func GetNetwork(networkID string) (*types.NetworkResource, error) { 13 | 14 | cli, err := getClient() 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | ctx := context.Background() 20 | 21 | filter := filters.NewArgs() 22 | filter.Match("Name", networkID) 23 | list, err := cli.NetworkList(ctx, types.NetworkListOptions{ 24 | Filters: filter, 25 | }) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | for _, net := range list { 31 | if net.Name == networkID { 32 | return inspectNetwork(networkID) 33 | } 34 | } 35 | 36 | _, err = cli.NetworkCreate(ctx, networkID, types.NetworkCreate{ 37 | CheckDuplicate: true, 38 | Attachable: true, 39 | Driver: "bridge", 40 | }) 41 | if err != nil { 42 | return nil, fmt.Errorf("NetworkCreate: %s", err) 43 | } 44 | 45 | return inspectNetwork(networkID) 46 | } 47 | 48 | func inspectNetwork(networkID string) (*types.NetworkResource, error) { 49 | cli, err := getClient() 50 | if err != nil { 51 | return nil, err 52 | } 53 | ctx := context.Background() 54 | net, err := cli.NetworkInspect(ctx, networkID, types.NetworkInspectOptions{}) 55 | if err != nil { 56 | return nil, fmt.Errorf("NetworkInspect: %s", err) 57 | } 58 | return &net, nil 59 | } 60 | 61 | func removeNetwork(networkID string) error { 62 | cli, err := getClient() 63 | if err != nil { 64 | return err 65 | } 66 | err = cli.NetworkRemove(context.Background(), networkID) 67 | if err != nil { 68 | return fmt.Errorf("NetworkRemove: %s", err) 69 | } 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /docker/network_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetNetwork(t *testing.T) { 8 | 9 | networkID := "test_redzilla1" 10 | 11 | removeNetwork(networkID) 12 | 13 | _, err := GetNetwork(networkID) 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | err = removeNetwork(networkID) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /docker/remove.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "github.com/docker/docker/api/types" 5 | "github.com/sirupsen/logrus" 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | //RemoveContainer remove a container instance 10 | func RemoveContainer(name string) error { 11 | 12 | logrus.Debugf("Removing container %s", name) 13 | 14 | cli, err := getClient() 15 | if err != nil { 16 | return err 17 | } 18 | 19 | ctx := context.Background() 20 | 21 | info, err := GetContainer(name) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if info.ContainerJSONBase == nil { 27 | logrus.Warnf("Cannot remove %s, does not exists", name) 28 | return nil 29 | } 30 | 31 | containerID := info.ContainerJSONBase.ID 32 | 33 | err = cli.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ 34 | Force: true, 35 | }) 36 | if err != nil { 37 | logrus.Warnf("ContainerRemove: %s", err) 38 | } 39 | 40 | logrus.Debugf("Removed container %s", name) 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /docker/start.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/docker/docker/api/types" 9 | "github.com/docker/docker/api/types/container" 10 | "github.com/docker/docker/api/types/network" 11 | "github.com/docker/go-connections/nat" 12 | "github.com/muka/redzilla/model" 13 | "github.com/muka/redzilla/storage" 14 | "github.com/sirupsen/logrus" 15 | "golang.org/x/net/context" 16 | ) 17 | 18 | //StartContainer start a container 19 | func StartContainer(name string, cfg *model.Config) error { 20 | 21 | logrus.Debugf("Starting docker container %s", name) 22 | 23 | cli, err := getClient() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | err = EnsureImage(cfg.ImageName) 29 | if err != nil { 30 | return fmt.Errorf("EnsureImge: %s", err) 31 | } 32 | 33 | _, err = GetNetwork(cfg.Network) 34 | if err != nil { 35 | return fmt.Errorf("GetNetwork: %s", err) 36 | } 37 | 38 | info, err := GetContainer(name) 39 | if err != nil { 40 | return fmt.Errorf("GetContainer: %s", err) 41 | } 42 | 43 | ctx := context.Background() 44 | 45 | exists := info.ContainerJSONBase != nil 46 | logrus.Debugf("Container %s exists: %t", name, exists) 47 | 48 | var containerID string 49 | 50 | if !exists { 51 | 52 | labels := map[string]string{ 53 | "redzilla": "1", 54 | "redzilla_instance": "redzilla_" + name, 55 | } 56 | 57 | exposedPorts := map[nat.Port]struct{}{ 58 | "1880/tcp": {}, 59 | } 60 | 61 | instanceConfigPath := storage.GetConfigPath(cfg) 62 | instanceDataPath := storage.GetInstancesDataPath(name, cfg) 63 | binds := []string{ 64 | instanceDataPath + ":/data", 65 | instanceConfigPath + ":/config", 66 | } 67 | 68 | envVars := extractEnv(cfg) 69 | 70 | logrus.Tracef("Creating new container %s ", name) 71 | logrus.Tracef("Bind paths: %v", binds) 72 | logrus.Tracef("Env: %v", envVars) 73 | 74 | resp, err1 := cli.ContainerCreate(ctx, 75 | &container.Config{ 76 | User: strconv.Itoa(os.Getuid()), // avoid permission issues 77 | Image: cfg.ImageName, 78 | AttachStdin: false, 79 | AttachStdout: true, 80 | AttachStderr: true, 81 | Tty: true, 82 | ExposedPorts: exposedPorts, 83 | Labels: labels, 84 | Env: envVars, 85 | }, 86 | &container.HostConfig{ 87 | Binds: binds, 88 | NetworkMode: container.NetworkMode(cfg.Network), 89 | PortBindings: nat.PortMap{ 90 | "1880": []nat.PortBinding{ 91 | nat.PortBinding{ 92 | HostIP: "", 93 | HostPort: "1880", 94 | }, 95 | }}, 96 | AutoRemove: true, 97 | 98 | // Links []string // List of links (in the name:alias form) 99 | PublishAllPorts: true, // Should docker publish all exposed port for the container 100 | // Mounts []mount.Mount `json:",omitempty"` 101 | }, 102 | // nil, 103 | &network.NetworkingConfig{}, 104 | name, 105 | ) 106 | if err1 != nil { 107 | return err1 108 | } 109 | 110 | containerID = resp.ID 111 | logrus.Debugf("Created new container %s", name) 112 | } else { 113 | containerID = info.ContainerJSONBase.ID 114 | logrus.Debugf("Reusing container %s", name) 115 | } 116 | 117 | logrus.Debugf("Starting `%s` (ID:%s)", name, containerID) 118 | 119 | if err = cli.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { 120 | return fmt.Errorf("ContainerStart: %s", err) 121 | } 122 | 123 | // _, err = cli.ContainerWait(ctx, containerID) 124 | // if err != nil { 125 | // return fmt.Errorf("Failed to start container: %s", err) 126 | // } 127 | 128 | // out, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ShowStdout: true}) 129 | // if err != nil { 130 | // return fmt.Errorf("Cannot open container logs: %s", err) 131 | // } 132 | // io.Copy(os.Stdout, out) 133 | 134 | logrus.Debugf("Started container %s", name) 135 | 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /docker/start_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/muka/redzilla/model" 7 | "github.com/sirupsen/logrus" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestStartContainerLocal(t *testing.T) { 12 | 13 | logrus.SetLevel(logrus.DebugLevel) 14 | 15 | // create local image 16 | dockerFilePath := "../test" 17 | imageName := "nodered_local1_test" 18 | 19 | err := BuildImage(imageName, dockerFilePath) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | networkID := "redzilla_test" 25 | containerName := "container_redz_test_local" 26 | cfg := &model.Config{ 27 | Network: networkID, 28 | ImageName: imageName, 29 | InstanceConfigPath: "../data/test", 30 | InstanceDataPath: "../data/test", 31 | } 32 | 33 | err = StartContainer(containerName, cfg) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | net, err := GetNetwork(networkID) 39 | assert.NotEmpty(t, net.Containers) 40 | 41 | err = StopContainer(containerName, true) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | } 47 | 48 | func TestStartContainerRemote(t *testing.T) { 49 | 50 | logrus.SetLevel(logrus.DebugLevel) 51 | 52 | containerName := "container_redz_test" 53 | cfg := &model.Config{ 54 | Network: "redzilla_test", 55 | ImageName: "nodered/node-red-docker:latest", 56 | InstanceConfigPath: "../data/test", 57 | InstanceDataPath: "../data/test", 58 | } 59 | 60 | err := StartContainer(containerName, cfg) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | err = StopContainer(containerName, true) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /docker/stop.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/sirupsen/logrus" 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | //StopContainer stop a container 12 | func StopContainer(name string, remove bool) error { 13 | 14 | logrus.Debugf("Stopping container %s", name) 15 | 16 | cli, err := getClient() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | ctx := context.Background() 22 | 23 | info, err := GetContainer(name) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if info.ContainerJSONBase == nil { 29 | logrus.Warnf("Cannot stop %s, does not exists", name) 30 | return nil 31 | } 32 | 33 | containerID := info.ContainerJSONBase.ID 34 | timeout := time.Second * 5 35 | 36 | err = cli.ContainerStop(ctx, containerID, &timeout) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if remove { 42 | err = cli.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ 43 | Force: true, 44 | RemoveLinks: true, 45 | }) 46 | if err != nil { 47 | logrus.Warnf("ContainerRemove: %s", err) 48 | } 49 | } 50 | 51 | logrus.Debugf("Stopped container %s", name) 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/muka/redzilla 2 | 3 | require ( 4 | github.com/Microsoft/go-winio v0.4.11 // indirect 5 | github.com/ddliu/go-httpclient v0.5.1 // indirect 6 | github.com/docker/distribution v2.7.1+incompatible // indirect 7 | github.com/docker/docker v1.13.1 8 | github.com/docker/go-connections v0.4.0 9 | github.com/docker/go-units v0.3.3 // indirect 10 | github.com/gin-contrib/sse v0.0.0-20190125020943-a7658810eb74 // indirect 11 | github.com/gin-gonic/gin v1.3.0 12 | github.com/golang/protobuf v1.2.0 // indirect 13 | github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25 // indirect 14 | github.com/json-iterator/go v1.1.5 // indirect 15 | github.com/mattn/go-isatty v0.0.4 // indirect 16 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 17 | github.com/modern-go/reflect2 v1.0.1 // indirect 18 | github.com/nanobox-io/golang-scribble v0.0.0-20180621225840-336beac0a992 19 | github.com/opencontainers/go-digest v1.0.0-rc1 // indirect 20 | github.com/opencontainers/runc v0.1.1 // indirect 21 | github.com/pkg/errors v0.8.1 // indirect 22 | github.com/sirupsen/logrus v1.3.0 23 | github.com/spf13/viper v1.3.1 24 | golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 25 | gopkg.in/go-playground/validator.v8 v8.18.2 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.4.11 h1:zoIOcVf0xPN1tnMVbTtEdI+P8OofVk3NObnwOQ6nK2Q= 2 | github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= 3 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 4 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 5 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 6 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/ddliu/go-httpclient v0.5.1 h1:ys4KozrhBaGdI1yuWIFwNNILqhnMU9ozTvRNfCTorvs= 9 | github.com/ddliu/go-httpclient v0.5.1/go.mod h1:8QVbjq00YK2f2MQyiKuWMdaKOFRcoD9VuubkNCNOuZo= 10 | github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= 11 | github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 12 | github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= 13 | github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 14 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 15 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 16 | github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= 17 | github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 18 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 19 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 20 | github.com/gin-contrib/sse v0.0.0-20190125020943-a7658810eb74 h1:FaI7wNyesdMBSkIRVUuEEYEvmzufs7EqQvRAxfEXGbQ= 21 | github.com/gin-contrib/sse v0.0.0-20190125020943-a7658810eb74/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= 22 | github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= 23 | github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= 24 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 25 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 26 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 27 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 28 | github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25 h1:EFT6MH3igZK/dIVqgGbTqWVvkZ7wJ5iGN03SVtvvdd8= 29 | github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25/go.mod h1:sWkGw/wsaHtRsT9zGQ/WyJCotGWG/Anow/9hsAcBWRw= 30 | github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswDE= 31 | github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 32 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 33 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 34 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 35 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 36 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 37 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 38 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 39 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 42 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 43 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 44 | github.com/nanobox-io/golang-scribble v0.0.0-20180621225840-336beac0a992 h1:zIRkVn8ZX7aGrS297sWNvZsyu99I3anNIHiMVpOuK/g= 45 | github.com/nanobox-io/golang-scribble v0.0.0-20180621225840-336beac0a992/go.mod h1:4Mct/lWCFf1jzQTTAaWtOI7sXqmG+wBeiBfT4CxoaJk= 46 | github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= 47 | github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= 48 | github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= 49 | github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= 50 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 51 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 52 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 53 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= 56 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 57 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 58 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 59 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 60 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 61 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 62 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 63 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 64 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 65 | github.com/spf13/viper v1.3.1 h1:5+8j8FTpnFV4nEImW/ofkzEt8VoOiLXxdYIDsB73T38= 66 | github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 67 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 68 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 69 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648= 70 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 71 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 72 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 73 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= 74 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 75 | golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80= 76 | golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 77 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 78 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A= 79 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 80 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 83 | gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= 84 | gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= 85 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 86 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 87 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/muka/redzilla/config" 9 | "github.com/muka/redzilla/model" 10 | "github.com/muka/redzilla/service" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func setupLogger(cfg *model.Config) error { 15 | 16 | lvl, err := logrus.ParseLevel(cfg.LogLevel) 17 | if err != nil { 18 | return fmt.Errorf("Failed to parse level %s: %s", cfg.LogLevel, err) 19 | } 20 | logrus.SetLevel(lvl) 21 | 22 | if lvl != logrus.DebugLevel { 23 | gin.SetMode(gin.ReleaseMode) 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func main() { 30 | 31 | err := config.Init() 32 | if err != nil { 33 | logrus.Errorf("Failed to init config: %s", err) 34 | os.Exit(1) 35 | } 36 | 37 | cfg, err := config.GetApiConfig() 38 | if err != nil { 39 | logrus.Errorf("Failed to get config: %s", err) 40 | os.Exit(1) 41 | } 42 | 43 | err = setupLogger(cfg) 44 | if err != nil { 45 | logrus.Errorf("Failed to setup logger: %s", err) 46 | os.Exit(1) 47 | } 48 | 49 | defer service.Stop(cfg) 50 | 51 | err = service.Start(cfg) 52 | if err != nil { 53 | logrus.Errorf("Error: %s", err.Error()) 54 | os.Exit(1) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /model/config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "html/template" 4 | 5 | // Config stores settings for the appliance 6 | type Config struct { 7 | Network string 8 | APIPort string 9 | Domain string 10 | ImageName string 11 | StorePath string 12 | InstanceDataPath string 13 | InstanceConfigPath string 14 | LogLevel string 15 | Autostart bool 16 | EnvPrefix string 17 | AuthType string 18 | AuthHttp *AuthHttp 19 | } 20 | 21 | type AuthHttp struct { 22 | Method string 23 | URL string 24 | Header string 25 | Body *template.Template 26 | } 27 | -------------------------------------------------------------------------------- /model/instance.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | //InstanceStatus last known state of an instance 6 | type InstanceStatus int 7 | 8 | var ( 9 | //InstanceDied not runnig due to failure 10 | InstanceDied InstanceStatus 11 | //InstanceStopped stopped by request 12 | InstanceStopped = InstanceStatus(10) 13 | //InstanceStarted started 14 | InstanceStarted = InstanceStatus(20) 15 | ) 16 | 17 | //NewInstance return a new json instance 18 | func NewInstance(name string) *Instance { 19 | return &Instance{ 20 | Name: name, 21 | Created: time.Now(), 22 | Status: InstanceStopped, 23 | } 24 | } 25 | 26 | // Instance is a contianer instance 27 | type Instance struct { 28 | Name string 29 | ID string 30 | Created time.Time 31 | Status InstanceStatus 32 | IP string 33 | Port string 34 | } 35 | -------------------------------------------------------------------------------- /service/cli.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/muka/redzilla/api" 5 | "github.com/muka/redzilla/docker" 6 | "github.com/muka/redzilla/model" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Start the service 11 | func Start(cfg *model.Config) error { 12 | 13 | msg := docker.ListenEvents(cfg) 14 | go func() { 15 | for { 16 | select { 17 | case ev := <-msg: 18 | 19 | instance := api.GetInstance(ev.Name, cfg) 20 | 21 | exists, err := instance.Exists() 22 | if err != nil { 23 | logrus.Errorf("Failed loading instance %s", ev.Name) 24 | continue 25 | } 26 | 27 | if !exists { 28 | continue 29 | } 30 | 31 | switch ev.Action { 32 | case "die": 33 | logrus.Warnf("Container exited %s", ev.Name) 34 | 35 | instance.StopLogsPipe() 36 | 37 | //reset cached informations 38 | rerr := instance.Reset() 39 | if rerr != nil { 40 | logrus.Warnf("Failed to reset detail for %s: %s", instance.GetStatus().Name, rerr.Error()) 41 | } 42 | 43 | break 44 | case "start": 45 | err = instance.StartLogsPipe() 46 | if err != nil { 47 | logrus.Warnf("Cannot start logs pipe for %s: %s", ev.Name, err.Error()) 48 | } 49 | 50 | //cache container IP 51 | instance.GetIP() 52 | instance.GetStatus().Status = model.InstanceStarted 53 | 54 | break 55 | default: 56 | logrus.Infof("Container %s %s", ev.Action, ev.Name) 57 | break 58 | } 59 | 60 | break 61 | } 62 | } 63 | }() 64 | 65 | err := api.Start(cfg) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // Stop the service 74 | func Stop(cfg *model.Config) { 75 | api.CloseInstanceLoggers() 76 | } 77 | -------------------------------------------------------------------------------- /storage/fileutil.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/muka/redzilla/model" 8 | ) 9 | 10 | // PathExists check if a path exists 11 | func PathExists(path string) (bool, error) { 12 | _, err := os.Stat(path) 13 | if err == nil { 14 | return true, nil 15 | } 16 | if os.IsNotExist(err) { 17 | return false, nil 18 | } 19 | return true, err 20 | } 21 | 22 | //CreateDir create a directory recursively 23 | func CreateDir(path string) error { 24 | exists, err := PathExists(path) 25 | if err != nil { 26 | return err 27 | } 28 | if exists { 29 | return nil 30 | } 31 | err = os.MkdirAll(path, 0777) 32 | if err != nil { 33 | return err 34 | } 35 | return nil 36 | } 37 | 38 | // GetInstancesDataPath return the path where instance data is stored 39 | func GetInstancesDataPath(name string, cfg *model.Config) string { 40 | path, err := filepath.Abs(cfg.InstanceDataPath) 41 | if err != nil { 42 | panic(err) 43 | } 44 | return filepath.Join(path, name) 45 | } 46 | 47 | // GetConfigPath return the path where shared config is stored 48 | func GetConfigPath(cfg *model.Config) string { 49 | path, err := filepath.Abs(cfg.InstanceConfigPath) 50 | if err != nil { 51 | panic(err) 52 | } 53 | return path 54 | } 55 | 56 | // GetStorePath return the path where instance data is stored 57 | func GetStorePath(name string, cfg *model.Config) string { 58 | path, err := filepath.Abs(cfg.StorePath) 59 | if err != nil { 60 | panic(err) 61 | } 62 | return filepath.Join(path, name) 63 | } 64 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "github.com/muka/redzilla/model" 6 | ) 7 | 8 | var store *Store 9 | 10 | //GetStore return the store instance 11 | func GetStore(collection string, cfg *model.Config) *Store { 12 | if store == nil { 13 | logrus.Debugf("Initializing store at %s", cfg.StorePath) 14 | store = NewStore(collection, cfg.StorePath) 15 | } 16 | return store 17 | } 18 | -------------------------------------------------------------------------------- /storage/store.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/nanobox-io/golang-scribble" 7 | ) 8 | 9 | //Store abstract a simple store 10 | type Store struct { 11 | filepath string 12 | db *scribble.Driver 13 | collection string 14 | } 15 | 16 | //NewStore create a new storage 17 | func NewStore(collection string, path string) *Store { 18 | 19 | // create a new scribble database, providing a destination for the database to live 20 | db, err := scribble.New(path, nil) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | err = CreateDir(filepath.Join(path, collection)) 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | s := Store{ 31 | filepath: path, 32 | db: db, 33 | collection: collection, 34 | } 35 | 36 | return &s 37 | } 38 | 39 | //Save a record 40 | func (s Store) Save(id string, record interface{}) error { 41 | return s.db.Write(s.collection, id, record) 42 | } 43 | 44 | //Load a record 45 | func (s Store) Load(id string, result interface{}) error { 46 | return s.db.Read(s.collection, id, result) 47 | } 48 | 49 | //Delete a record 50 | func (s Store) Delete(id string) error { 51 | return s.db.Delete(s.collection, id) 52 | } 53 | 54 | //List all records 55 | func (s Store) List() ([]string, error) { 56 | return s.db.ReadAll(s.collection) 57 | } 58 | -------------------------------------------------------------------------------- /test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nodered/node-red-docker:latest 2 | RUN echo "1" > tmp.test 3 | -------------------------------------------------------------------------------- /test/build_fail/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nodered/node-red-docker:latest 2 | RUN fail command 3 | --------------------------------------------------------------------------------