diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9875a66..f3afe20 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,4 +22,4 @@ jobs: run: go build -v . - name: Test - run: go test -v . + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0de95ef..fbaebe6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,4 +22,4 @@ jobs: - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release -b main -p "@semantic-release/commit-analyzer" -p "@semantic-release/release-notes-generator" -p "@semantic-release/github" \ No newline at end of file + run: npx semantic-release -b main -b beta \ No newline at end of file diff --git a/README.md b/README.md index 7dcc708..7e0c15d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ - # Traefik Ondemand Plugin - Traefik middleware to start containers on demand. ![Github Actions](https://img.shields.io/github/workflow/status/acouvreur/traefik-ondemand-plugin/Build?style=flat-square) @@ -17,15 +15,35 @@ Traefik middleware to start containers on demand. - Start your container/service on the first request - Automatic **scale to zero** after configured timeout upon last request the service received - Dynamic loading page (cloudflare or grafana cloud style) +- Customize dynamic and loading pages ![Demo](./img/ondemand.gif) + ## Usage ### Plugin configuration +#### Strategies + +**Dynamic Strategy** + +_Serve an HTML page that self reload._ + +```yml +testData: + serviceUrl: http://ondemand:10000 + name: TRAEFIK_HACKATHON_whoami + timeout: 1m + waitUi: true +``` + +**Blocking Strategy** + +_Responds as soon as the service is up with a maximum waiting time of `blockingDelay`_ + #### Custom loading/error pages -The `loadingpage` and `errorpage` keys in the plugin configuration can be used to override the default loading and error pages. +The `loadingpage` and `errorpage` keys in the plugin configuration can be used to override the default loading and error pages. The value should be a path where a template that can be parsed by Go's [html/template](https://pkg.go.dev/html/template) package can be found in the Traefik container. @@ -33,23 +51,30 @@ An example of both a loading page and an error page template can be found in the The plugin will default to the built-in loading and error pages if these fields are omitted. +You must include `` inside your html page to get auto refresh. + **Example Configuration** + ```yml testData: serviceUrl: http://ondemand:10000 name: TRAEFIK_HACKATHON_whoami timeout: 1m + waitUi: false + blockingDelay: 1m loadingpage: /opt/on-demand/loading.html errorpage: /opt/on-demand/error.html ``` -| Parameter | Type | Example | Description | -| ------------ | --------------- | -------------------------- | ----------------------------------------------------------------------- | -| `serviceUrl` | `string` | `http://ondemand:10000` | The docker container name, or the swarm service name | -| `name` | `string` | `TRAEFIK_HACKATHON_whoami` | The container/service to be stopped (docker ps | docker service ls) | -| `timeout` | `time.Duration` | `1m30s` | The duration after which the container/service will be scaled down to 0 | -| `loadingpage`| `string` | `/opt/on-demand/loading.html` | The path in the traefik container for the loading page template | -| `errorpage` | `string` | `/opt/on-demand/error.html` | The path in the traefik container for the error page template | +| Parameter | Type | Example | Description | +| --------------- | --------------- | ----------------------------- | ------------------------------------------------------------------------------------- | +| `serviceUrl` | `string` | `http://ondemand:10000` | The docker container name, or the swarm service name | +| `name` | `string` | `TRAEFIK_HACKATHON_whoami` | The container/service to be stopped (docker ps docker service ls) | +| `timeout` | `time.Duration` | `1m30s` | The duration after which the container/service will be scaled down to 0 | +| `waitUi` | `bool` | `true` | Serves a self-refreshing html page when the service is scaled down to 0 | +| `blockingDelay` | `time.Duration` | `1m30s` | When `waitUi` is `false`, wait for the service to be scaled up before `blockingDelay` | +| `loadingpage` | `string` | `/opt/on-demand/loading.html` | The path in the traefik container for the loading page template | +| `errorpage` | `string` | `/opt/on-demand/error.html` | The path in the traefik container for the error page template | ### Traefik-Ondemand-Service @@ -66,6 +91,11 @@ The docker library that interacts with the docker deamon uses `unsafe` which mus - [Multiple Containers](./examples/multiple_containers/) - [Kubernetes](./examples/kubernetes/) +## Development + +`export TRAEFIK_PILOT_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` +`docker stack deploy -c docker-compose.yml TRAEFIK_HACKATHON` + ## Authors [Alexis Couvreur](https://www.linkedin.com/in/alexis-couvreur/) (left) diff --git a/config.yml b/config.yml deleted file mode 100644 index 8b59ca8..0000000 --- a/config.yml +++ /dev/null @@ -1,23 +0,0 @@ -http: - middlewares: - ondemand: - plugin: - ondemand: - serviceurl: http://ondemand:10000 - name: TRAEFIK_HACKATHON_whoami - timeout: 1m - - services: - whoami: - loadBalancer: - servers: - - url: "http://whoami:80" - - routers: - whoami: - rule: PathPrefix(`/whoami`) - entryPoints: - - "http" - middlewares: - - ondemand - service: "whoami" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 520bf40..b2bb278 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,6 @@ services: volumes: - './traefik_dev.yml:/etc/traefik/traefik-template.yml' - '/var/run/docker.sock:/var/run/docker.sock' - - './config.yml:/etc/traefik/config.yml' - '.:/plugins-local/src/github.com/acouvreur/traefik-ondemand-plugin' environment: - TRAEFIK_PILOT_TOKEN @@ -20,11 +19,47 @@ services: - traefik.http.services.traefik.loadbalancer.server.port=8080 ondemand: - image: ghcr.io/acouvreur/traefik-ondemand-service:1 + image: ghcr.io/acouvreur/traefik-ondemand-service:1.7 + command: + - --swarmMode=true volumes: - '/var/run/docker.sock:/var/run/docker.sock' whoami: image: containous/whoami deploy: - replicas: 0 \ No newline at end of file + replicas: 0 + labels: + - traefik.enable=true + # If you do not use the swarm load balancer, traefik will evict the service from its pool + # as soon as the service is 0/0. If you do not set that, fallback to dynamic-config.yml file usage. + - traefik.docker.lbswarm=true + - traefik.http.middlewares.ondemand_whoami.plugin.traefik-ondemand-plugin.name=TRAEFIK_HACKATHON_whoami + - traefik.http.middlewares.ondemand_whoami.plugin.traefik-ondemand-plugin.serviceurl=http://ondemand:10000 + - traefik.http.middlewares.ondemand_whoami.plugin.traefik-ondemand-plugin.timeout=1m + - traefik.http.routers.whoami.middlewares=ondemand_whoami@docker + - traefik.http.routers.whoami.rule=PathPrefix(`/whoami`) + - traefik.http.services.whoami.loadbalancer.server.port=80 + + nginx: + image: nginx + healthcheck: + test: "true" + interval: 1m30s + timeout: 30s + retries: 5 + start_period: 30s + deploy: + replicas: 0 + labels: + - traefik.enable=true + # If you do not use the swarm load balancer, traefik will evict the service from its pool + # as soon as the service is 0/0. If you do not set that, fallback to dynamic-config.yml file usage. + - traefik.docker.lbswarm=true + - traefik.http.middlewares.ondemand_nginx.plugin.traefik-ondemand-plugin.name=TRAEFIK_HACKATHON_nginx + - traefik.http.middlewares.ondemand_nginx.plugin.traefik-ondemand-plugin.serviceurl=http://ondemand:10000 + - traefik.http.middlewares.ondemand_nginx.plugin.traefik-ondemand-plugin.timeout=5m + - traefik.http.middlewares.ondemand_nginx.plugin.traefik-ondemand-plugin.waitUi=false + - traefik.http.routers.nginx.middlewares=ondemand_nginx@docker + - traefik.http.routers.nginx.rule=PathPrefix(`/nginx`) + - traefik.http.services.nginx.loadbalancer.server.port=80 \ No newline at end of file diff --git a/ondemand.go b/ondemand.go index 4d61805..6a9de14 100644 --- a/ondemand.go +++ b/ondemand.go @@ -3,20 +3,12 @@ package traefik_ondemand_plugin import ( "context" "fmt" - "io/ioutil" - "log" "net/http" - "strings" "time" - "github.com/acouvreur/traefik-ondemand-plugin/pkg/pages" + "github.com/acouvreur/traefik-ondemand-plugin/pkg/strategy" ) -// Net client is a custom client to timeout after 2 seconds if the service is not ready -var netClient = &http.Client{ - Timeout: time.Second * 2, -} - // Config the plugin configuration type Config struct { Name string `yaml:"name"` @@ -24,23 +16,24 @@ type Config struct { Timeout string `yaml:"timeout"` ErrorPage string `yaml:"errorpage"` LoadingPage string `yaml:"loadingpage"` + WaitUi bool `yaml:"waitUi"` + BlockDelay string `yaml:"blockDelay"` } // CreateConfig creates a config with its default values func CreateConfig() *Config { return &Config{ - Timeout: "1m", + Timeout: "1m", + WaitUi: true, + BlockDelay: "1m", + ErrorPage: "", + LoadingPage: "", } } // Ondemand holds the request for the on demand service type Ondemand struct { - request string - name string - next http.Handler - timeout time.Duration - errorpage string - loadingpage string + strategy strategy.Strategy } func buildRequest(url string, name string, timeout time.Duration) (string, error) { @@ -70,56 +63,47 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h return nil, fmt.Errorf("error while building request") } + strategy, err := config.getServeStrategy(request, name, next, timeout) + + if err != nil { + return nil, err + } + return &Ondemand{ - next: next, - name: config.Name, - request: request, - timeout: timeout, - errorpage: config.ErrorPage, - loadingpage: config.LoadingPage, + strategy: strategy, }, nil } +func (config *Config) getServeStrategy(request string, name string, next http.Handler, timeout time.Duration) (strategy.Strategy, error) { + if config.WaitUi { + return &strategy.DynamicStrategy{ + Request: request, + Name: name, + Next: next, + Timeout: timeout, + ErrorPage: config.ErrorPage, + LoadingPage: config.LoadingPage, + }, nil + } else { + + blockDelay, err := time.ParseDuration(config.BlockDelay) + + if err != nil { + return nil, err + } + + return &strategy.BlockingStrategy{ + Request: request, + Name: name, + Next: next, + Timeout: timeout, + BlockDelay: blockDelay, + BlockCheckInterval: 1 * time.Second, + }, nil + } +} + // ServeHTTP retrieve the service status func (e *Ondemand) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - - log.Printf("Sending request: %s", e.request) - status, err := getServiceStatus(e.request) - log.Printf("Status: %s", status) - - if err != nil { - rw.WriteHeader(http.StatusInternalServerError) - rw.Write([]byte(pages.GetErrorPage(e.errorpage, e.name, err.Error()))) - } - - if status == "started" { - // Service started forward request - e.next.ServeHTTP(rw, req) - - } else if status == "starting" { - // Service starting, notify client - rw.WriteHeader(http.StatusAccepted) - rw.Write([]byte(pages.GetLoadingPage(e.loadingpage, e.name, e.timeout))) - } else { - // Error - rw.WriteHeader(http.StatusInternalServerError) - rw.Write([]byte(pages.GetErrorPage(e.errorpage, e.name, status))) - } -} - -func getServiceStatus(request string) (string, error) { - - // This request wakes up the service if he's scaled to 0 - resp, err := netClient.Get(request) - if err != nil { - return "error", err - } - - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "parsing error", err - } - - return strings.TrimSuffix(string(body), "\n"), nil + e.strategy.ServeHTTP(rw, req) } diff --git a/ondemand_test.go b/ondemand_test.go index 998aed7..ba00507 100644 --- a/ondemand_test.go +++ b/ondemand_test.go @@ -2,9 +2,7 @@ package traefik_ondemand_plugin import ( "context" - "fmt" "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -26,10 +24,22 @@ func TestNewOndemand(t *testing.T) { expectedError: true, }, { - desc: "valid Config", + desc: "valid Dynamic Config", config: &Config{ Name: "whoami", ServiceUrl: "http://ondemand:1000", + WaitUi: true, + Timeout: "1m", + }, + expectedError: false, + }, + { + desc: "valid Blocking Config", + config: &Config{ + Name: "whoami", + ServiceUrl: "http://ondemand:1000", + WaitUi: false, + BlockDelay: "1m", Timeout: "1m", }, expectedError: false, @@ -53,58 +63,3 @@ func TestNewOndemand(t *testing.T) { }) } } - -func TestOndemand_ServeHTTP(t *testing.T) { - testCases := []struct { - desc string - status string - expected int - }{ - { - desc: "service is starting", - status: "starting", - expected: 202, - }, - { - desc: "service is started", - status: "started", - expected: 200, - }, - { - desc: "ondemand service is in error", - status: "error", - expected: 500, - }, - } - - for _, test := range testCases { - test := test - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, test.status) - })) - - defer mockServer.Close() - - config := &Config{ - Name: "whoami", - ServiceUrl: mockServer.URL, - Timeout: "1m", - } - ondemand, err := New(context.Background(), next, config, "traefikTest") - require.NoError(t, err) - - recorder := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodGet, "http://mydomain/whoami", nil) - - ondemand.ServeHTTP(recorder, req) - - assert.Equal(t, test.expected, recorder.Code) - }) - } -} diff --git a/pkg/pages/error.go b/pkg/pages/error.go index 9edc562..f12250f 100644 --- a/pkg/pages/error.go +++ b/pkg/pages/error.go @@ -182,7 +182,7 @@ func GetErrorPage(template_path string, name string, e string) string { if template_path != "" { tpl, err = template.New(path.Base(template_path)).ParseFiles(template_path) } else { - tpl, err = template.New("loading").Parse(loadingPage) + tpl, err = template.New("loading").Parse(errorPage) } if err != nil { return err.Error() diff --git a/pkg/strategy/blocking_strategy.go b/pkg/strategy/blocking_strategy.go new file mode 100644 index 0000000..8111f1a --- /dev/null +++ b/pkg/strategy/blocking_strategy.go @@ -0,0 +1,51 @@ +package strategy + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type BlockingStrategy struct { + Request string + Name string + Next http.Handler + Timeout time.Duration + BlockDelay time.Duration + BlockCheckInterval time.Duration +} + +type InternalServerError struct { + ServiceName string `json:"serviceName"` + Error string `json:"error"` +} + +// ServeHTTP retrieve the service status +func (e *BlockingStrategy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + + for start := time.Now(); time.Since(start) < e.BlockDelay; { + log.Printf("Sending request: %s", e.Request) + status, err := getServiceStatus(e.Request) + log.Printf("Status: %s", status) + + if err != nil { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(rw).Encode(InternalServerError{ServiceName: e.Name, Error: err.Error()}) + return + } + + if status == "started" { + // Service started forward request + e.Next.ServeHTTP(rw, req) + return + } + time.Sleep(e.BlockCheckInterval) + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(rw).Encode(InternalServerError{ServiceName: e.Name, Error: fmt.Sprintf("Service was unreachable within %s", e.BlockDelay)}) +} diff --git a/pkg/strategy/blocking_strategy_test.go b/pkg/strategy/blocking_strategy_test.go new file mode 100644 index 0000000..1f2f61a --- /dev/null +++ b/pkg/strategy/blocking_strategy_test.go @@ -0,0 +1,73 @@ +package strategy + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestBlockingStrategy_ServeHTTP(t *testing.T) { + testCases := []struct { + desc string + body string + status int + expected int + }{ + { + desc: "service keeps on starting", + body: "starting", + status: 200, + expected: 503, + }, + { + desc: "service is started", + body: "started", + status: 200, + expected: 200, + }, + { + desc: "ondemand service is in error", + body: "error", + status: 503, + expected: 500, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(test.status) + fmt.Fprint(w, test.body) + })) + + defer mockServer.Close() + + blockingStrategy := &BlockingStrategy{ + Name: "whoami", + Request: mockServer.URL, + Next: next, + BlockDelay: 1 * time.Second, + } + + recorder := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "http://mydomain/whoami", nil) + + blockingStrategy.ServeHTTP(recorder, req) + + assert.Equal(t, test.expected, recorder.Code) + }) + } +} diff --git a/pkg/strategy/dynamic_strategy.go b/pkg/strategy/dynamic_strategy.go new file mode 100644 index 0000000..03b5d2f --- /dev/null +++ b/pkg/strategy/dynamic_strategy.go @@ -0,0 +1,46 @@ +package strategy + +import ( + "log" + "net/http" + "time" + + "github.com/acouvreur/traefik-ondemand-plugin/pkg/pages" +) + +type DynamicStrategy struct { + Request string + Name string + Next http.Handler + Timeout time.Duration + LoadingPage string + ErrorPage string +} + +// ServeHTTP retrieve the service status +func (e *DynamicStrategy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + + log.Printf("Sending request: %s", e.Request) + status, err := getServiceStatus(e.Request) + log.Printf("Status: %s", status) + + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + rw.Write([]byte(pages.GetErrorPage(e.ErrorPage, e.Name, err.Error()))) + } + + if status == "started" { + // Service started forward request + e.Next.ServeHTTP(rw, req) + + } else if status == "starting" { + // Service starting, notify client + rw.WriteHeader(http.StatusAccepted) + rw.Write([]byte(pages.GetLoadingPage(e.LoadingPage, e.Name, e.Timeout))) + } else { + // Error + rw.WriteHeader(http.StatusInternalServerError) + rw.Write([]byte(pages.GetErrorPage(e.ErrorPage, e.Name, status))) + } + +} diff --git a/pkg/strategy/dynamic_strategy_test.go b/pkg/strategy/dynamic_strategy_test.go new file mode 100644 index 0000000..f1e77a2 --- /dev/null +++ b/pkg/strategy/dynamic_strategy_test.go @@ -0,0 +1,63 @@ +package strategy + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDynamicStrategy_ServeHTTP(t *testing.T) { + testCases := []struct { + desc string + status string + expected int + }{ + { + desc: "service is starting", + status: "starting", + expected: 202, + }, + { + desc: "service is started", + status: "started", + expected: 200, + }, + { + desc: "ondemand service is in error", + status: "error", + expected: 500, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.status) + })) + + defer mockServer.Close() + + dynamicStrategy := &DynamicStrategy{ + Name: "whoami", + Request: mockServer.URL, + Next: next, + } + + recorder := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "http://mydomain/whoami", nil) + + dynamicStrategy.ServeHTTP(recorder, req) + + assert.Equal(t, test.expected, recorder.Code) + }) + } +} diff --git a/pkg/strategy/strategy.go b/pkg/strategy/strategy.go new file mode 100644 index 0000000..5a60a90 --- /dev/null +++ b/pkg/strategy/strategy.go @@ -0,0 +1,39 @@ +package strategy + +import ( + "errors" + "io/ioutil" + "net/http" + "strings" + "time" +) + +// Net client is a custom client to timeout after 2 seconds if the service is not ready +var netClient = &http.Client{ + Timeout: time.Second * 2, +} + +type Strategy interface { + ServeHTTP(rw http.ResponseWriter, req *http.Request) +} + +func getServiceStatus(request string) (string, error) { + + // This request wakes up the service if he's scaled to 0 + resp, err := netClient.Get(request) + if err != nil { + return "error", err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "parsing error", err + } + + if resp.StatusCode >= 400 { + return "error from ondemand service", errors.New(string(body)) + } + + return strings.TrimSuffix(string(body), "\n"), nil +} diff --git a/release.config.js b/release.config.js new file mode 100644 index 0000000..1f899af --- /dev/null +++ b/release.config.js @@ -0,0 +1,11 @@ +module.exports = { + "branches": [ + { "name": "main" }, + { "name": "beta", "channel": "beta", "prerelease": "beta" }, + ], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] +} \ No newline at end of file diff --git a/traefik_dev.yml b/traefik_dev.yml index b1998d4..9e36cdb 100755 --- a/traefik_dev.yml +++ b/traefik_dev.yml @@ -7,7 +7,7 @@ api: experimental: localPlugins: - ondemand: + traefik-ondemand-plugin: moduleName: github.com/acouvreur/traefik-ondemand-plugin entryPoints: @@ -19,7 +19,4 @@ entryPoints: providers: docker: swarmMode: true - exposedByDefault: false - file: - filename: "/etc/traefik/config.yml" - watch: true \ No newline at end of file + exposedByDefault: false \ No newline at end of file