diff --git a/.traefik.yml b/.traefik.yml index 278ac42..36ddb7d 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -7,7 +7,14 @@ import: github.com/acouvreur/sablier/plugins/traefik summary: "Start your containers on demand, shut them down automatically when there's no activity. Docker, Docker Swarm Mode and Kubernetes compatible." testData: - serviceUrl: http://sablier:10000 - name: whoami - timeout: 1m - \ No newline at end of file + sablierUrl: http://sablier:10000 + names: whoami,nginx # comma separated names + sessionDuration: 1m + # Dynamic strategy, provides the waiting webui + dynamic: + displayName: My Title + theme: hacker-terminal + # Blocking strategy, waits until services are up and running + # but will not wait more than `timeout` + blocking: + timeout: 1m \ No newline at end of file diff --git a/app/http/routes/models/blocking_request.go b/app/http/routes/models/blocking_request.go index 319fa7f..9a56d94 100644 --- a/app/http/routes/models/blocking_request.go +++ b/app/http/routes/models/blocking_request.go @@ -3,7 +3,7 @@ package models import "time" type BlockingRequest struct { - Names []string - SessionDuration time.Duration - Timeout time.Duration + Names []string `form:"names" binding:"required"` + SessionDuration time.Duration `form:"session_duration" binding:"required"` + Timeout time.Duration `form:"timeout"` } diff --git a/app/http/routes/models/dynamic_request.go b/app/http/routes/models/dynamic_request.go index 8e3ccc5..d687d39 100644 --- a/app/http/routes/models/dynamic_request.go +++ b/app/http/routes/models/dynamic_request.go @@ -6,7 +6,7 @@ import ( type DynamicRequest struct { Names []string `form:"names" binding:"required"` - DisplayName string `form:"display-name" binding:"required"` - Theme string `form:"theme" binding:"required"` - SessionDuration time.Duration `form:"session-duration" binding:"required"` + DisplayName string `form:"display_name"` + Theme string `form:"theme"` + SessionDuration time.Duration `form:"session_duration" binding:"required"` } diff --git a/app/http/routes/strategies.go b/app/http/routes/strategies.go index d2f6317..2ec0d85 100644 --- a/app/http/routes/strategies.go +++ b/app/http/routes/strategies.go @@ -34,9 +34,9 @@ func (s *ServeStrategy) ServeDynamic(c *gin.Context) { sessionState := s.SessionsManager.RequestSession(request.Names, request.SessionDuration) if sessionState.IsReady() { - // All requests are fulfilled, redirect to - c.Redirect(http.StatusTemporaryRedirect, "origin") - return + c.Header("X-Sablier-Session-Status", "ready") + } else { + c.Header("X-Sablier-Session-Status", "not-ready") } renderOptions := pages.RenderOptions{ @@ -67,9 +67,9 @@ func (s *ServeStrategy) ServeBlocking(c *gin.Context) { sessionState := s.SessionsManager.RequestReadySession(request.Names, request.SessionDuration, request.Timeout) if sessionState.IsReady() { - // All requests are fulfilled, redirect to - c.Redirect(http.StatusTemporaryRedirect, "origin") - return + c.Header("X-Sablier-Session-Status", "ready") + } else { + c.Header("X-Sablier-Session-Status", "not-ready") } } diff --git a/app/http/routes/strategies_test.go b/app/http/routes/strategies_test.go index 719ac11..88aabd3 100644 --- a/app/http/routes/strategies_test.go +++ b/app/http/routes/strategies_test.go @@ -43,9 +43,10 @@ func TestServeStrategy_ServeDynamic(t *testing.T) { session sessions.SessionState } tests := []struct { - name string - arg arg - expectedCode int + name string + arg arg + expectedHeaderKey string + expectedHeaderValue string }{ { name: "return HTML Theme", @@ -62,7 +63,8 @@ func TestServeStrategy_ServeDynamic(t *testing.T) { }), }, }, - expectedCode: http.StatusOK, + expectedHeaderKey: "X-Sablier-Session-Status", + expectedHeaderValue: "not-ready", }, { name: "temporary redirect when session is ready", @@ -79,7 +81,8 @@ func TestServeStrategy_ServeDynamic(t *testing.T) { }), }, }, - expectedCode: http.StatusTemporaryRedirect, + expectedHeaderKey: "X-Sablier-Session-Status", + expectedHeaderValue: "ready", }, } for _, tt := range tests { @@ -99,7 +102,7 @@ func TestServeStrategy_ServeDynamic(t *testing.T) { res := recorder.Result() defer res.Body.Close() - assert.Equal(t, c.Writer.Status(), tt.expectedCode) + assert.Equal(t, c.Writer.Header().Get(tt.expectedHeaderKey), tt.expectedHeaderValue) }) } } diff --git a/app/sessions/sessions_manager.go b/app/sessions/sessions_manager.go index ad51f77..b323a5a 100644 --- a/app/sessions/sessions_manager.go +++ b/app/sessions/sessions_manager.go @@ -140,7 +140,7 @@ func (s *SessionsManager) requestSessionInstance(name string, duration time.Dura } func (s *SessionsManager) RequestReadySession(names []string, duration time.Duration, timeout time.Duration) *SessionState { - return nil + return s.RequestSession(names, duration) } func (s *SessionsManager) ExpiresAfter(instance *instance.State, duration time.Duration) { diff --git a/plugins/traefik/config.go b/plugins/traefik/config.go new file mode 100644 index 0000000..3ff1c7a --- /dev/null +++ b/plugins/traefik/config.go @@ -0,0 +1,122 @@ +package traefik + +import ( + "fmt" + "net/http" + "strings" +) + +type DynamicConfiguration struct { + DisplayName string `yaml:"displayname"` + Theme string `yaml:"theme"` +} + +type BlockingConfiguration struct { + Timeout string `yaml:"timeout"` +} + +type Config struct { + SablierURL string `yaml:"sablierUrl"` + Names string `yaml:"names"` + SessionDuration string `yaml:"sessionDuration"` + splittedNames []string + Dynamic *DynamicConfiguration `yaml:"dynamic"` + Blocking *BlockingConfiguration `yaml:"blocking"` + Next http.Handler +} + +func CreateConfig() *Config { + return &Config{ + SablierURL: "http://sablier:10000", + Names: "", + SessionDuration: "", + splittedNames: []string{}, + Dynamic: nil, + Blocking: nil, + } +} + +func (c *Config) BuildRequest() (*http.Request, error) { + + if len(c.SablierURL) == 0 { + return nil, fmt.Errorf("sablierURL cannot be empty") + } + + names := strings.Split(c.Names, ",") + for i := range names { + names[i] = strings.TrimSpace(names[i]) + } + + c.splittedNames = names + + if len(names) == 0 { + return nil, fmt.Errorf("you must specify at least one name") + } + + if c.Dynamic != nil && c.Blocking != nil { + return nil, fmt.Errorf("only supply one strategy: dynamic or blocking") + } + + if c.Dynamic != nil { + return c.buildDynamicRequest() + } else if c.Blocking != nil { + return c.buildBlockingRequest() + } + return nil, fmt.Errorf("no strategy configured") +} + +func (c *Config) buildDynamicRequest() (*http.Request, error) { + if c.Dynamic == nil { + return nil, fmt.Errorf("dynamic config is nil") + } + + request, err := http.NewRequest("GET", fmt.Sprintf("%s/api/strategies/dynamic", c.SablierURL), nil) + if err != nil { + return nil, err + } + + q := request.URL.Query() + + q.Add("session_duration", c.SessionDuration) + for _, name := range c.splittedNames { + q.Add("names", name) + } + + if c.Dynamic.DisplayName != "" { + q.Add("display_name", c.Dynamic.DisplayName) + } + + if c.Dynamic.Theme != "" { + q.Add("theme", c.Dynamic.Theme) + } + + request.URL.RawQuery = q.Encode() + + return request, nil +} + +func (c *Config) buildBlockingRequest() (*http.Request, error) { + if c.Blocking == nil { + return nil, fmt.Errorf("blocking config is nil") + } + + request, err := http.NewRequest("GET", fmt.Sprintf("%s/api/strategies/blocking", c.SablierURL), nil) + if err != nil { + return nil, err + } + + q := request.URL.Query() + + q.Add("session_duration", c.SessionDuration) + for _, name := range c.splittedNames { + q.Add("names", name) + } + + if c.Blocking.Timeout != "" { + q.Add("timeout", c.Blocking.Timeout) + } + + request.URL.RawQuery = q.Encode() + + return request, nil +} diff --git a/plugins/traefik/config_test.go b/plugins/traefik/config_test.go new file mode 100644 index 0000000..9f2f1f3 --- /dev/null +++ b/plugins/traefik/config_test.go @@ -0,0 +1,129 @@ +package traefik_test + +import ( + "io" + "net/http" + "reflect" + "testing" + + "github.com/acouvreur/sablier/plugins/traefik" +) + +func TestConfig_BuildRequest(t *testing.T) { + type fields struct { + SablierURL string + Names string + SessionDuration string + Dynamic *traefik.DynamicConfiguration + Blocking *traefik.BlockingConfiguration + } + tests := []struct { + name string + fields fields + want *http.Request + wantErr bool + }{ + { + name: "dynamic session with default values", + fields: fields{ + SablierURL: "http://sablier:10000", + Names: "nginx , apache", + SessionDuration: "1m", + Dynamic: &traefik.DynamicConfiguration{}, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/dynamic?names=nginx&names=apache&session_duration=1m", nil), + wantErr: false, + }, + { + name: "dynamic session with theme values", + fields: fields{ + SablierURL: "http://sablier:10000", + Names: "nginx , apache", + SessionDuration: "1m", + Dynamic: &traefik.DynamicConfiguration{ + Theme: "hacker-terminal", + }, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/dynamic?names=nginx&names=apache&session_duration=1m&theme=hacker-terminal", nil), + wantErr: false, + }, + { + name: "dynamic session with theme and display name values", + fields: fields{ + SablierURL: "http://sablier:10000", + Names: "nginx , apache", + SessionDuration: "1m", + Dynamic: &traefik.DynamicConfiguration{ + Theme: "hacker-terminal", + DisplayName: "Hello World!", + }, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/dynamic?display_name=Hello+World%21&names=nginx&names=apache&session_duration=1m&theme=hacker-terminal", nil), + wantErr: false, + }, + { + name: "blocking session with default values", + fields: fields{ + SablierURL: "http://sablier:10000", + Names: "nginx , apache", + SessionDuration: "1m", + Blocking: &traefik.BlockingConfiguration{}, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/blocking?names=nginx&names=apache&session_duration=1m", nil), + wantErr: false, + }, + { + name: "blocking session with timeout value", + fields: fields{ + SablierURL: "http://sablier:10000", + Names: "nginx , apache", + SessionDuration: "1m", + Blocking: &traefik.BlockingConfiguration{ + Timeout: "5m", + }, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/blocking?names=nginx&names=apache&session_duration=1m&timeout=5m", nil), + wantErr: false, + }, + { + name: "both strategies defined", + fields: fields{ + SablierURL: "http://sablier:10000", + Names: "nginx , apache", + SessionDuration: "1m", + Dynamic: &traefik.DynamicConfiguration{}, + Blocking: &traefik.BlockingConfiguration{}, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &traefik.Config{ + SablierURL: tt.fields.SablierURL, + Names: tt.fields.Names, + SessionDuration: tt.fields.SessionDuration, + Dynamic: tt.fields.Dynamic, + Blocking: tt.fields.Blocking, + } + + got, err := c.BuildRequest() + if (err != nil) != tt.wantErr { + t.Errorf("Config.BuildRequest() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Config.BuildRequest() = %v, want %v", got, tt.want) + } + }) + } +} + +func createRequest(method string, url string, body io.Reader) *http.Request { + request, err := http.NewRequest(method, url, body) + if err != nil { + panic(err) + } + return request +} diff --git a/plugins/traefik/main.go b/plugins/traefik/main.go new file mode 100644 index 0000000..a7bba45 --- /dev/null +++ b/plugins/traefik/main.go @@ -0,0 +1,51 @@ +package traefik + +import ( + "context" + "io" + "net/http" +) + +type SablierMiddleware struct { + client *http.Client + request *http.Request + Next http.Handler +} + +// New function creates the configuration +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + req, err := config.BuildRequest() + + if err != nil { + return nil, err + } + + return &SablierMiddleware{ + request: req, + client: &http.Client{}, + Next: config.Next, + }, nil +} + +func (sm *SablierMiddleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + sablierRequest := sm.request.Clone(context.TODO()) + + resp, err := sm.client.Do(sablierRequest) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.Header.Get("X-Sablier-Session-Status") == "ready" { + sm.Next.ServeHTTP(rw, req) + } else { + forward(resp, rw) + } +} + +func forward(resp *http.Response, rw http.ResponseWriter) { + rw.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + rw.Header().Set("Content-Length", resp.Header.Get("Content-Length")) + io.Copy(rw, resp.Body) +} diff --git a/plugins/traefik/main_test.go b/plugins/traefik/main_test.go new file mode 100644 index 0000000..af2b756 --- /dev/null +++ b/plugins/traefik/main_test.go @@ -0,0 +1,97 @@ +package traefik + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSablierMiddleware_ServeHTTP(t *testing.T) { + type fields struct { + Next http.Handler + Config *Config + } + type sablier struct { + headers map[string]string + body string + } + tests := []struct { + name string + fields fields + sablier sablier + expected string + }{ + { + name: "sablier service is ready", + sablier: sablier{ + headers: map[string]string{ + "X-Sablier-Session-Status": "ready", + }, + body: "response from sablier", + }, + fields: fields{ + Config: &Config{ + Dynamic: &DynamicConfiguration{}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "response from service") + }), + }, + }, + expected: "response from service", + }, + { + name: "sablier service is not ready", + sablier: sablier{ + headers: map[string]string{ + "X-Sablier-Session-Status": "not-ready", + }, + body: "response from sablier", + }, + fields: fields{ + Config: &Config{ + Dynamic: &DynamicConfiguration{}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "response from service") + }), + }, + }, + expected: "response from sablier", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sablierMockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for key, value := range tt.sablier.headers { + w.Header().Add(key, value) + } + w.Write([]byte(tt.sablier.body)) + })) + defer sablierMockServer.Close() + + tt.fields.Config.SablierURL = sablierMockServer.URL + + sm, err := New(context.Background(), tt.fields.Next, tt.fields.Config, "middleware") + if err != nil { + panic(err) + } + + req := httptest.NewRequest(http.MethodGet, "/my-nginx", nil) + w := httptest.NewRecorder() + + sm.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + if string(data) != tt.expected { + t.Errorf("expected %s got %v", tt.expected, string(data)) + } + }) + } +} diff --git a/plugins/traefik/ondemand.go b/plugins/traefik/ondemand.go deleted file mode 100644 index 9579983..0000000 --- a/plugins/traefik/ondemand.go +++ /dev/null @@ -1,126 +0,0 @@ -package traefik - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/acouvreur/sablier/plugins/traefik/pkg/strategy" -) - -// Config the plugin configuration -type Config struct { - Name string `yaml:"name"` - Names []string `yaml:"names"` - ServiceUrl string `yaml:"serviceurl"` - Timeout string `yaml:"timeout"` - ErrorPage string `yaml:"errorpage"` - LoadingPage string `yaml:"loadingpage"` - WaitUi bool `yaml:"waitui"` - DisplayName string `yaml:"displayname"` - BlockDelay string `yaml:"blockdelay"` -} - -// CreateConfig creates a config with its default values -func CreateConfig() *Config { - return &Config{ - Timeout: "1m", - WaitUi: true, - BlockDelay: "1m", - DisplayName: "", - ErrorPage: "", - LoadingPage: "", - } -} - -// Ondemand holds the request for the on demand service -type Ondemand struct { - strategy strategy.Strategy -} - -func buildRequest(url string, name string, timeout time.Duration) (string, error) { - request := fmt.Sprintf("%s?name=%s&timeout=%s", url, name, timeout.String()) - return request, nil -} - -// New function creates the configuration -func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { - if len(config.ServiceUrl) == 0 { - return nil, fmt.Errorf("serviceurl cannot be null") - } - - if len(config.Name) != 0 && len(config.Names) != 0 { - return nil, fmt.Errorf("both name and names cannot be used simultaneously") - } - var serviceNames []string - - if len(config.Name) != 0 { - serviceNames = append(serviceNames, config.Name) - } else if len(config.Names) != 0 { - serviceNames = config.Names - } else { - return nil, fmt.Errorf("both name and names cannot be null") - } - - timeout, err := time.ParseDuration(config.Timeout) - - if err != nil { - return nil, err - } - var requests []string - - for _, serviceName := range serviceNames { - request, err := buildRequest(config.ServiceUrl, serviceName, timeout) - - if err != nil { - return nil, fmt.Errorf("error while building request for %s", serviceName) - } - requests = append(requests, request) - } - - strategy, err := config.getServeStrategy(requests, name, next, timeout) - - if err != nil { - return nil, err - } - - return &Ondemand{ - strategy: strategy, - }, nil -} - -func (config *Config) getServeStrategy(requests []string, name string, next http.Handler, timeout time.Duration) (strategy.Strategy, error) { - if config.WaitUi { - return &strategy.DynamicStrategy{ - Requests: requests, - Name: name, - Next: next, - Timeout: timeout, - DisplayName: config.DisplayName, - ErrorPage: config.ErrorPage, - LoadingPage: config.LoadingPage, - }, nil - } else { - - blockDelay, err := time.ParseDuration(config.BlockDelay) - - if err != nil { - return nil, err - } - - return &strategy.BlockingStrategy{ - Requests: requests, - 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) { - e.strategy.ServeHTTP(rw, req) -} diff --git a/plugins/traefik/ondemand_test.go b/plugins/traefik/ondemand_test.go deleted file mode 100644 index f77d347..0000000 --- a/plugins/traefik/ondemand_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package traefik - -import ( - "context" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewOndemand(t *testing.T) { - testCases := []struct { - desc string - config *Config - expectedError bool - }{ - { - desc: "Invalid Config (no name)", - config: &Config{ - ServiceUrl: "http://ondemand:1000", - Timeout: "1m", - }, - expectedError: true, - }, - { - desc: "Invalid Config (empty names)", - config: &Config{ - Names: []string{}, - ServiceUrl: "http://ondemand:1000", - Timeout: "1m", - }, - expectedError: true, - }, - { - desc: "Invalid Config (empty serviceUrl)", - config: &Config{ - Name: "whoami", - ServiceUrl: "", - Timeout: "1m", - }, - expectedError: true, - }, - { - desc: "Invalid Config (name and names used simultaneously)", - config: &Config{ - Names: []string{ - "whoami-1", "whoami-2", - }, - Name: "whoami", - ServiceUrl: "http://ondemand:1000", - WaitUi: true, - BlockDelay: "1m", - Timeout: "1m", - }, - expectedError: true, - }, - { - 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, - }, - { - desc: "valid Dynamic Multiple Config", - config: &Config{ - Names: []string{ - "whoami-1", "whoami-2", - }, - ServiceUrl: "http://ondemand:1000", - WaitUi: false, - BlockDelay: "1m", - Timeout: "1m", - }, - expectedError: false, - }, - { - desc: "valid Blocking Multiple Config", - config: &Config{ - Names: []string{ - "whoami-1", "whoami-2", - }, - ServiceUrl: "http://ondemand:1000", - WaitUi: true, - BlockDelay: "1m", - Timeout: "1m", - }, - expectedError: false, - }, - } - - 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) {}) - ondemand, err := New(context.Background(), next, test.config, "traefikTest") - - if test.expectedError { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.NotNil(t, ondemand) - } - }) - } -} diff --git a/plugins/traefik/pkg/strategy/blocking_strategy.go b/plugins/traefik/pkg/strategy/blocking_strategy.go deleted file mode 100644 index 37d29c9..0000000 --- a/plugins/traefik/pkg/strategy/blocking_strategy.go +++ /dev/null @@ -1,59 +0,0 @@ -package strategy - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "time" -) - -type BlockingStrategy struct { - Requests []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; { - notReadyCount := 0 - for _, request := range e.Requests { - - log.Printf("Sending request: %s", request) - status, err := getServiceStatus(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" { - notReadyCount++ - } - } - if notReadyCount == 0 { - // Services all 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/plugins/traefik/pkg/strategy/blocking_strategy_test.go b/plugins/traefik/pkg/strategy/blocking_strategy_test.go deleted file mode 100644 index fb3b896..0000000 --- a/plugins/traefik/pkg/strategy/blocking_strategy_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package strategy - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestSingleBlockingStrategy_ServeHTTP(t *testing.T) { - for _, test := range SingleServiceTestCases { - 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.onDemandServiceResponses[0].status) - fmt.Fprint(w, test.onDemandServiceResponses[0].body) - })) - - defer mockServer.Close() - - blockingStrategy := &BlockingStrategy{ - Name: "whoami", - Requests: []string{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.blocking, recorder.Code) - }) - } -} - -func TestMultipleBlockingStrategy_ServeHTTP(t *testing.T) { - - for _, test := range MultipleServicesTestCases { - test := test - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - urls := make([]string, len(test.onDemandServiceResponses)) - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) - }) - - for responseIndex, response := range test.onDemandServiceResponses { - response := response - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(response.status) - fmt.Fprint(w, response.body) - })) - - defer mockServer.Close() - urls[responseIndex] = mockServer.URL - } - fmt.Println(urls) - blockingStrategy := &BlockingStrategy{ - Name: "whoami", - Requests: urls, - 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.blocking, recorder.Code) - }) - } -} diff --git a/plugins/traefik/pkg/strategy/dynamic_strategy.go b/plugins/traefik/pkg/strategy/dynamic_strategy.go deleted file mode 100644 index 6671a2f..0000000 --- a/plugins/traefik/pkg/strategy/dynamic_strategy.go +++ /dev/null @@ -1,62 +0,0 @@ -package strategy - -import ( - "log" - "net/http" - "time" - - "github.com/acouvreur/sablier/plugins/traefik/pkg/pages" -) - -type DynamicStrategy struct { - Requests []string - Name string - Next http.Handler - Timeout time.Duration - DisplayName string - LoadingPage string - ErrorPage string -} - -// ServeHTTP retrieve the service status -func (e *DynamicStrategy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - started := make([]bool, len(e.Requests)) - - displayName := e.Name - if len(e.DisplayName) > 0 { - displayName = e.DisplayName - } - - notReadyCount := 0 - for requestIndex, request := range e.Requests { - log.Printf("Sending request: %s", request) - status, err := getServiceStatus(request) - log.Printf("Status: %s", status) - - if err != nil { - rw.WriteHeader(http.StatusInternalServerError) - rw.Write([]byte(pages.GetErrorPage(e.ErrorPage, displayName, err.Error()))) - return - } - - if status == "started" { - started[requestIndex] = true - } else if status == "starting" { - started[requestIndex] = false - notReadyCount++ - } else { - // Error - rw.WriteHeader(http.StatusInternalServerError) - rw.Write([]byte(pages.GetErrorPage(e.ErrorPage, displayName, status))) - return - } - } - if notReadyCount == 0 { - // All services are ready, forward request - e.Next.ServeHTTP(rw, req) - } else { - // Services still starting, notify client - rw.WriteHeader(http.StatusAccepted) - rw.Write([]byte(pages.GetLoadingPage(e.LoadingPage, displayName, e.Timeout))) - } -} diff --git a/plugins/traefik/pkg/strategy/dynamic_strategy_test.go b/plugins/traefik/pkg/strategy/dynamic_strategy_test.go deleted file mode 100644 index d4efeb4..0000000 --- a/plugins/traefik/pkg/strategy/dynamic_strategy_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package strategy - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSingleDynamicStrategy_ServeHTTP(t *testing.T) { - - for _, test := range SingleServiceTestCases { - 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.onDemandServiceResponses[0].body) - })) - - defer mockServer.Close() - - dynamicStrategy := &DynamicStrategy{ - Name: "whoami", - Requests: []string{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.dynamic, recorder.Code) - }) - } -} - -func TestMultipleDynamicStrategy_ServeHTTP(t *testing.T) { - for _, test := range MultipleServicesTestCases { - test := test - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - - urls := make([]string, len(test.onDemandServiceResponses)) - for responseIndex, response := range test.onDemandServiceResponses { - response := response - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, response.body) - })) - defer mockServer.Close() - - urls[responseIndex] = mockServer.URL - } - dynamicStrategy := &DynamicStrategy{ - Name: "whoami", - Requests: urls, - Next: next, - } - - recorder := httptest.NewRecorder() - - req := httptest.NewRequest(http.MethodGet, "http://mydomain/whoami", nil) - - dynamicStrategy.ServeHTTP(recorder, req) - - assert.Equal(t, test.expected.dynamic, recorder.Code) - }) - } -} diff --git a/plugins/traefik/pkg/strategy/strategy.go b/plugins/traefik/pkg/strategy/strategy.go deleted file mode 100644 index d695234..0000000 --- a/plugins/traefik/pkg/strategy/strategy.go +++ /dev/null @@ -1,44 +0,0 @@ -package strategy - -import ( - "encoding/json" - "errors" - "net/http" - "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 SablierResponse struct { - State string `json:"state"` - Error string `json:"error"` -} - -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 - } - - decoder := json.NewDecoder(resp.Body) - var response SablierResponse - err = decoder.Decode(&response) - if err != nil { - return "error from ondemand service", err - } - - if resp.StatusCode >= 400 { - return "error from ondemand service", errors.New(response.Error) - } - - return response.State, nil -} diff --git a/plugins/traefik/pkg/strategy/strategy_test_cases.go b/plugins/traefik/pkg/strategy/strategy_test_cases.go deleted file mode 100644 index a152645..0000000 --- a/plugins/traefik/pkg/strategy/strategy_test_cases.go +++ /dev/null @@ -1,141 +0,0 @@ -package strategy - -import "encoding/json" - -type OnDemandServiceResponses struct { - body string - status int -} -type ExpectedStatusForStrategy struct { - dynamic int - blocking int -} - -type TestCase struct { - desc string - onDemandServiceResponses []OnDemandServiceResponses - expected ExpectedStatusForStrategy -} - -var SingleServiceTestCases = []TestCase{ - { - desc: "service is / keeps on starting", - onDemandServiceResponses: GenerateServicesResponses(1, "starting"), - expected: ExpectedStatusForStrategy{ - dynamic: 202, - blocking: 503, - }, - }, - { - desc: "service is started", - onDemandServiceResponses: GenerateServicesResponses(1, "started"), - expected: ExpectedStatusForStrategy{ - dynamic: 200, - blocking: 200, - }, - }, - { - desc: "ondemand service is in error", - onDemandServiceResponses: GenerateServicesResponses(1, "error"), - expected: ExpectedStatusForStrategy{ - dynamic: 500, - blocking: 500, - }, - }, -} - -func GenerateServicesResponses(count int, serviceBody string) []OnDemandServiceResponses { - body, err := json.Marshal(SablierResponse{State: serviceBody, Error: "ko"}) - if err != nil { - return nil - } - responses := make([]OnDemandServiceResponses, count) - for i := 0; i < count; i++ { - if serviceBody == "starting" || serviceBody == "started" { - responses[i] = OnDemandServiceResponses{ - body: string(body), - status: 200, - } - } else { - responses[i] = OnDemandServiceResponses{ - body: string(body), - status: 503, - } - } - } - return responses -} - -var MultipleServicesTestCases = []TestCase{ - { - desc: "all services are starting", - onDemandServiceResponses: GenerateServicesResponses(5, "starting"), - expected: ExpectedStatusForStrategy{ - dynamic: 202, - blocking: 503, - }, - }, - { - desc: "one started others are starting", - onDemandServiceResponses: append(GenerateServicesResponses(1, "starting"), GenerateServicesResponses(4, "started")...), - expected: ExpectedStatusForStrategy{ - dynamic: 202, - blocking: 503, - }, - }, - { - desc: "one starting others are started", - onDemandServiceResponses: append(GenerateServicesResponses(4, "starting"), GenerateServicesResponses(1, "started")...), - expected: ExpectedStatusForStrategy{ - dynamic: 202, - blocking: 503, - }, - }, - { - desc: "one errored others are starting", - onDemandServiceResponses: append( - GenerateServicesResponses(2, "starting"), - append( - GenerateServicesResponses(1, "error"), - GenerateServicesResponses(2, "starting")..., - )..., - ), - expected: ExpectedStatusForStrategy{ - dynamic: 500, - blocking: 500, - }, - }, - { - desc: "one errored others are started", - onDemandServiceResponses: append( - GenerateServicesResponses(1, "error"), - GenerateServicesResponses(4, "started")..., - ), - expected: ExpectedStatusForStrategy{ - dynamic: 500, - blocking: 500, - }, - }, - { - desc: "one errored others are mix of starting / started", - onDemandServiceResponses: append( - GenerateServicesResponses(2, "started"), - append( - GenerateServicesResponses(1, "error"), - GenerateServicesResponses(2, "starting")..., - )..., - ), - expected: ExpectedStatusForStrategy{ - dynamic: 500, - blocking: 500, - }, - }, - { - desc: "all are started", - onDemandServiceResponses: GenerateServicesResponses(5, "started"), - expected: ExpectedStatusForStrategy{ - dynamic: 200, - blocking: 200, - }, - }, -}