feat: add blocking request (#12)

* feat: add blocking strategy

* docs: add examples for blocking strategy

* ci: run go tests recursively

* perf: wait for BlockCheckInterval for each request

* fix: use camel case instead of snake case for yaml config

* docs: add loading and error page customization as a feature

* fix: use errorPage

* fix: return json instead of html page

* set json key

* update development config

* docs: add comment about custom loading pages

* ci: add beta release
This commit is contained in:
Alexis Couvreur
2021-12-11 15:46:56 +01:00
committed by GitHub
parent 327211cbbf
commit b4f5eebaac
15 changed files with 425 additions and 164 deletions

View File

@@ -22,4 +22,4 @@ jobs:
run: go build -v .
- name: Test
run: go test -v .
run: go test -v ./...

View File

@@ -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"
run: npx semantic-release -b main -b beta

View File

@@ -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 `<meta http-equiv="refresh" content="5" />` 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

39
pkg/strategy/strategy.go Normal file
View File

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

11
release.config.js Normal file
View File

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

View File

@@ -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
exposedByDefault: false