diff --git a/.traefik.yml b/.traefik.yml index f3de044..ea1829a 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -9,6 +9,7 @@ summary: "Start your containers on demand, shut them down automatically when the testData: sablierUrl: http://sablier:10000 # The sablier URL service, must be reachable from the Traefik instance names: whoami,nginx # Comma separated names of containers/services/deployments etc. + group: default # Group name to use to filter by label, ignored if names is set sessionDuration: 1m # The session duration after which containers/services/deployments instances are shutdown # You can only use one strategy at a time # To do so, only declare `dynamic` or `blocking` diff --git a/README.md b/README.md index 87da746..bfec620 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Which allows you to start your containers on demand and shut them down automatic - [Configuration File](#configuration-file) - [Environment Variables](#environment-variables) - [Arguments](#arguments) + - [](#) - [Install Sablier on its own](#install-sablier-on-its-own) - [Use the Docker image](#use-the-docker-image) - [Use the binary distribution](#use-the-binary-distribution) @@ -33,6 +34,10 @@ Which allows you to start your containers on demand and shut them down automatic - [Sablier Healthcheck](#sablier-healthcheck) - [Using the `/health` route](#using-the-health-route) - [Using the `sablier health` command](#using-the-sablier-health-command) + - [Autodiscovery using labels](#autodiscovery-using-labels) + - [Docker labels](#docker-labels) + - [Docker swarm service labels](#docker-swarm-service-labels) + - [Kubernetes deployments labels](#kubernetes-deployments-labels) - [API](#api) - [GET `/api/strategies/dynamic`](#get-apistrategiesdynamic) - [GET `/api/strategies/blocking`](#get-apistrategiesblocking) @@ -85,7 +90,7 @@ It leverage the API calls to Sablier to your reverse proxy middleware to wake up | ------------- | :-------------------------------------------------------: | :---------------: | :-----------: | :-------------------------------------------------------: | | Traefik | ✅ | ✅ | ✅ *(partial)* | [See #70](https://github.com/acouvreur/sablier/issues/70) | | Nginx | ✅ | ✅ | ❌ | -| Apache | *Coming soon* +| Apache | *Coming soon* | | Caddy | [See #67](https://github.com/acouvreur/sablier/issues/67) | ### Traefik @@ -213,6 +218,8 @@ Becomes sablier start --strategy.dynamic.custom-themes-path /my/path ``` +### + ## Install Sablier on its own You can install Sablier with the following flavors: @@ -337,6 +344,68 @@ services: interval: 1m30s ``` +## Autodiscovery using labels + +Instead of specifying the names of the instances you want to use, you can take advantage of the labels to specify groups of containers. + +- `sablier.enable=true` +- `sablier.group=mygroup` (*optional*) defaults to "default" + +You can then use the API by specifying the group instead of the container names. + +``` +curl -X GET -v "http://localhost:10000/api/strategies/blocking?group=mygroup&session_duration=5m&timeout=5s" +``` + +### Docker labels + +```yaml +services: + whoami: + image: containous/whoami:v1.5.0 + labels: + - sablier.enable=true + - sablier.group=mygroup +``` + +### Docker swarm service labels + +```yaml +services: + whoami: + image: containous/whoami:v1.5.0 + deploy: + labels: + - sablier.enable=true + - sablier.group=mygroup +``` + +### Kubernetes deployments labels + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: whoami-deployment + labels: + app: whoami + sablier.enable: true + sablier.group: mygroup +spec: + replicas: 0 + selector: + matchLabels: + app: whoami + template: + metadata: + labels: + app: whoami + spec: + containers: + - name: whoami + image: containous/whoami:v1.5.0 +``` + ## API To run the following examples you can create two containers: @@ -348,14 +417,15 @@ To run the following examples you can create two containers: **Description**: The `/api/strategies/dynamic` endpoint allows you to request a waiting page for multiple instances -| Parameter | Value | Description | -| -------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `names` | array of string | The instances to be started | -| `session_duration` | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The session duration for all services, which will reset at each subsequent calls | -| `show_details` *(optional)* | bool | The details about instances | -| `display_name` *(optional)* | string | The display name | -| `theme` *(optional)* | string | The theme to use | -| `refresh_frequency` *(optional)* | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The refresh frequency for the loading page | +| Parameter | Value | Description | +| -------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `names` | array of string | The instances to be started (cannot be used with `group` parameter) | +| `group` | string | The instance group to be started (using `sablier.group=mygroup` labels) (cannot be used with `names` parameter) | +| `session_duration` | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The session duration for all services, which will reset at each subsequent calls | +| `show_details` *(optional)* | bool | The details about instances | +| `display_name` *(optional)* | string | The display name | +| `theme` *(optional)* | string | The theme to use | +| `refresh_frequency` *(optional)* | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The refresh frequency for the loading page | Go to http://localhost:10000/api/strategies/dynamic?names=nginx&names=apache&session_duration=5m&show_details=true&display_name=example&theme=hacker-terminal&refresh_frequency=10s and you should see @@ -367,11 +437,12 @@ A special header `X-Sablier-Session-Status` is returned and will have the value **Description**: The `/api/strategies/blocking` endpoint allows you to wait until the instances are ready -| Parameter | Value | Description | -| ---------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `names` | array of string | The instances to be started | -| `session_duration` | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The session duration for all services, which will reset at each subsequent calls | -| `timeout` *(optional)* | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The maximum time to wait for instances to be ready | +| Parameter | Value | Description | +| ---------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `names` | array of string | The instances to be started (cannot be used with `group` parameter) | +| `group` | string | The instance group to be started (using `sablier.group=mygroup` labels) (cannot be used with `names` parameter) | +| `session_duration` | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The session duration for all services, which will reset at each subsequent calls | +| `timeout` *(optional)* | duration [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) | The maximum time to wait for instances to be ready | A special header `X-Sablier-Session-Status` is returned and will have the value `ready` if all instances are ready. Or else `not-ready`. diff --git a/app/http/routes/models/blocking_request.go b/app/http/routes/models/blocking_request.go index 6e82f12..ef7e556 100644 --- a/app/http/routes/models/blocking_request.go +++ b/app/http/routes/models/blocking_request.go @@ -3,7 +3,8 @@ package models import "time" type BlockingRequest struct { - Names []string `form:"names" binding:"required"` + Names []string `form:"names"` + Group string `form:"group"` SessionDuration time.Duration `form:"session_duration"` Timeout time.Duration `form:"timeout"` } diff --git a/app/http/routes/models/dynamic_request.go b/app/http/routes/models/dynamic_request.go index c367668..0689fd2 100644 --- a/app/http/routes/models/dynamic_request.go +++ b/app/http/routes/models/dynamic_request.go @@ -5,7 +5,8 @@ import ( ) type DynamicRequest struct { - Names []string `form:"names" binding:"required"` + Group string `form:"group"` + Names []string `form:"names"` ShowDetails bool `form:"show_details"` DisplayName string `form:"display_name"` Theme string `form:"theme"` diff --git a/app/http/routes/sessions.go b/app/http/routes/sessions.go deleted file mode 100644 index eec2b07..0000000 --- a/app/http/routes/sessions.go +++ /dev/null @@ -1,19 +0,0 @@ -package routes - -import "github.com/gin-gonic/gin" - -func GetSessions(c *gin.Context) { - -} - -func GetSession(c *gin.Context) { - -} - -func PutSession(c *gin.Context) { - -} - -func DeleteSession(c *gin.Context) { - -} diff --git a/app/http/routes/strategies.go b/app/http/routes/strategies.go index 833feab..3b98aa0 100644 --- a/app/http/routes/strategies.go +++ b/app/http/routes/strategies.go @@ -61,7 +61,17 @@ func (s *ServeStrategy) ServeDynamic(c *gin.Context) { return } - sessionState := s.SessionsManager.RequestSession(request.Names, request.SessionDuration) + var sessionState *sessions.SessionState + if len(request.Names) > 0 { + sessionState = s.SessionsManager.RequestSession(request.Names, request.SessionDuration) + } else { + sessionState = s.SessionsManager.RequestSessionGroup(request.Group, request.SessionDuration) + } + + if sessionState == nil { + c.AbortWithStatus(http.StatusNotFound) + return + } if sessionState.IsReady() { c.Header("X-Sablier-Session-Status", "ready") @@ -119,7 +129,23 @@ func (s *ServeStrategy) ServeBlocking(c *gin.Context) { return } - sessionState, err := s.SessionsManager.RequestReadySession(c.Request.Context(), request.Names, request.SessionDuration, request.Timeout) + var sessionState *sessions.SessionState + var err error + if len(request.Names) > 0 { + sessionState, err = s.SessionsManager.RequestReadySession(c.Request.Context(), request.Names, request.SessionDuration, request.Timeout) + } else { + sessionState, err = s.SessionsManager.RequestReadySessionGroup(c.Request.Context(), request.Group, request.SessionDuration, request.Timeout) + } + + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + if sessionState == nil { + c.AbortWithStatus(http.StatusNotFound) + return + } if err != nil { 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 a7eb13b..25f6285 100644 --- a/app/http/routes/strategies_test.go +++ b/app/http/routes/strategies_test.go @@ -25,6 +25,7 @@ import ( type SessionsManagerMock struct { SessionState sessions.SessionState + sessions.Manager } func (s *SessionsManagerMock) RequestSession(names []string, duration time.Duration) *sessions.SessionState { diff --git a/app/providers/docker_classic.go b/app/providers/docker_classic.go index f2501a8..dca2be2 100644 --- a/app/providers/docker_classic.go +++ b/app/providers/docker_classic.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "io" + "strings" "github.com/acouvreur/sablier/app/instance" "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" log "github.com/sirupsen/logrus" @@ -24,12 +26,44 @@ func NewDockerClassicProvider() (*DockerClassicProvider, error) { log.Fatal(fmt.Errorf("%+v", "Could not connect to docker API")) return nil, err } + return &DockerClassicProvider{ Client: cli, desiredReplicas: 1, }, nil } +func (provider *DockerClassicProvider) GetGroups() (map[string][]string, error) { + ctx := context.Background() + + filters := filters.NewArgs() + filters.Add("label", fmt.Sprintf("%s=true", enableLabel)) + + containers, err := provider.Client.ContainerList(ctx, types.ContainerListOptions{ + All: true, + Filters: filters, + }) + + if err != nil { + return nil, err + } + + groups := make(map[string][]string) + for _, container := range containers { + groupName := container.Labels[groupLabel] + if len(groupName) == 0 { + groupName = defaultGroupValue + } + group := groups[groupName] + group = append(group, strings.TrimPrefix(container.Names[0], "/")) + groups[groupName] = group + } + + log.Debug(fmt.Sprintf("%v", groups)) + + return groups, nil +} + func (provider *DockerClassicProvider) Start(name string) (instance.State, error) { ctx := context.Background() @@ -111,25 +145,22 @@ func (provider *DockerClassicProvider) NotifyInstanceStopped(ctx context.Context msgs, errs := provider.Client.Events(ctx, types.EventsOptions{ Filters: filters.NewArgs( filters.Arg("scope", "local"), - filters.Arg("type", "container"), + filters.Arg("type", events.ContainerEventType), filters.Arg("event", "die"), ), }) - - go func() { - for { - select { - case msg := <-msgs: - // Send the container that has died to the channel - instance <- msg.Actor.Attributes["name"] - case err := <-errs: - if errors.Is(err, io.EOF) { - log.Debug("provider event stream closed") - return - } - case <-ctx.Done(): + for { + select { + case msg := <-msgs: + // Send the container that has died to the channel + instance <- strings.TrimPrefix(msg.Actor.Attributes["name"], "/") + case err := <-errs: + if errors.Is(err, io.EOF) { + log.Debug("provider event stream closed") return } + case <-ctx.Done(): + return } - }() + } } diff --git a/app/providers/docker_swarm.go b/app/providers/docker_swarm.go index 6b6b69e..01aa032 100644 --- a/app/providers/docker_swarm.go +++ b/app/providers/docker_swarm.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "sync" "github.com/acouvreur/sablier/app/instance" "github.com/docker/docker/api/types" @@ -17,6 +18,8 @@ import ( type DockerSwarmProvider struct { Client client.APIClient + updateGroups chan any + groups *sync.Map desiredReplicas int } @@ -28,7 +31,10 @@ func NewDockerSwarmProvider() (*DockerSwarmProvider, error) { return &DockerSwarmProvider{ Client: cli, desiredReplicas: 1, + updateGroups: make(chan any, 1), + groups: &sync.Map{}, }, nil + } func (provider *DockerSwarmProvider) Start(name string) (instance.State, error) { @@ -68,6 +74,43 @@ func (provider *DockerSwarmProvider) scale(name string, replicas uint64) (instan return instance.NotReadyInstanceState(foundName, 0, provider.desiredReplicas) } +func (provider *DockerSwarmProvider) GetGroups() (map[string][]string, error) { + ctx := context.Background() + + filters := filters.NewArgs() + filters.Add("label", fmt.Sprintf("%s=true", enableLabel)) + + services, err := provider.Client.ServiceList(ctx, types.ServiceListOptions{ + Filters: filters, + }) + + if err != nil { + return nil, err + } + + groups := make(map[string][]string) + for _, service := range services { + groupName := service.Spec.Labels[groupLabel] + if len(groupName) == 0 { + groupName = defaultGroupValue + } + + group := groups[groupName] + group = append(group, service.Spec.Name) + groups[groupName] = group + } + + return groups, nil +} + +func (provider *DockerSwarmProvider) GetGroup(group string) []string { + containers, ok := provider.groups.Load(group) + if !ok { + return []string{} + } + return containers.([]string) +} + func (provider *DockerSwarmProvider) GetState(name string) (instance.State, error) { ctx := context.Background() diff --git a/app/providers/kubernetes.go b/app/providers/kubernetes.go index 5fb8410..026d00c 100644 --- a/app/providers/kubernetes.go +++ b/app/providers/kubernetes.go @@ -69,9 +69,11 @@ func NewKubernetesProvider() (*KubernetesProvider, error) { if err != nil { return nil, err } + return &KubernetesProvider{ Client: client, }, nil + } func (provider *KubernetesProvider) Start(name string) (instance.State, error) { @@ -93,6 +95,32 @@ func (provider *KubernetesProvider) Stop(name string) (instance.State, error) { } +func (provider *KubernetesProvider) GetGroups() (map[string][]string, error) { + ctx := context.Background() + + deployments, err := provider.Client.AppsV1().Deployments(core_v1.NamespaceAll).List(ctx, metav1.ListOptions{ + LabelSelector: enableLabel, + }) + + if err != nil { + return nil, err + } + + groups := make(map[string][]string) + for _, deployment := range deployments.Items { + groupName := deployment.Labels[groupLabel] + if len(groupName) == 0 { + groupName = defaultGroupValue + } + + group := groups[groupName] + group = append(group, deployment.Name) + groups[groupName] = group + } + + return groups, nil +} + func (provider *KubernetesProvider) scale(config *Config, replicas int32) (instance.State, error) { ctx := context.Background() @@ -174,10 +202,10 @@ func (provider *KubernetesProvider) getStatefulsetState(config *Config) (instanc func (provider *KubernetesProvider) NotifyInstanceStopped(ctx context.Context, instance chan<- string) { - inforemer := provider.watchDeployents(instance) - go inforemer.Run(ctx.Done()) - inforemer = provider.watchStatefulSets(instance) - go inforemer.Run(ctx.Done()) + informer := provider.watchDeployents(instance) + go informer.Run(ctx.Done()) + informer = provider.watchStatefulSets(instance) + go informer.Run(ctx.Done()) } func (provider *KubernetesProvider) watchDeployents(instance chan<- string) cache.SharedIndexInformer { diff --git a/app/providers/provider.go b/app/providers/provider.go index b030054..6d9f4bd 100644 --- a/app/providers/provider.go +++ b/app/providers/provider.go @@ -8,10 +8,15 @@ import ( "github.com/acouvreur/sablier/config" ) +const enableLabel = "sablier.enable" +const groupLabel = "sablier.group" +const defaultGroupValue = "default" + type Provider interface { Start(name string) (instance.State, error) Stop(name string) (instance.State, error) GetState(name string) (instance.State, error) + GetGroups() (map[string][]string, error) NotifyInstanceStopped(ctx context.Context, instance chan<- string) } diff --git a/app/sessions/groups_watcher.go b/app/sessions/groups_watcher.go new file mode 100644 index 0000000..11f0166 --- /dev/null +++ b/app/sessions/groups_watcher.go @@ -0,0 +1,27 @@ +package sessions + +import ( + "context" + "time" + + "github.com/acouvreur/sablier/app/providers" + log "github.com/sirupsen/logrus" +) + +// watchGroups watches indefinitely for new groups +func watchGroups(ctx context.Context, provider providers.Provider, frequency time.Duration, send chan<- map[string][]string) { + ticker := time.NewTicker(frequency) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + groups, err := provider.GetGroups() + if err != nil { + log.Warn("could not get groups", err) + } else { + send <- groups + } + } + } +} diff --git a/app/sessions/mocks/provider_mock.go b/app/sessions/mocks/provider_mock.go index 7a8452f..c262746 100644 --- a/app/sessions/mocks/provider_mock.go +++ b/app/sessions/mocks/provider_mock.go @@ -53,6 +53,10 @@ func (provider *ProviderMock) GetState(name string) (instance.State, error) { return args.Get(0).(instance.State), args.Error(1) } +func (provider *ProviderMock) GetGroups() (map[string][]string, error) { + return make(map[string][]string), nil +} + type KVMock[T any] struct { wg sync.WaitGroup diff --git a/app/sessions/sessions_manager.go b/app/sessions/sessions_manager.go index bc3255e..ab29e0c 100644 --- a/app/sessions/sessions_manager.go +++ b/app/sessions/sessions_manager.go @@ -14,9 +14,13 @@ import ( log "github.com/sirupsen/logrus" ) +const defaultRefreshFrequency = 2 * time.Second + type Manager interface { RequestSession(names []string, duration time.Duration) *SessionState + RequestSessionGroup(group string, duration time.Duration) *SessionState RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error) + RequestReadySessionGroup(ctx context.Context, group string, duration time.Duration, timeout time.Duration) (*SessionState, error) LoadSessions(io.ReadCloser) error SaveSessions(io.WriteCloser) error @@ -25,36 +29,58 @@ type Manager interface { } type SessionsManager struct { - events context.Context + ctx context.Context cancel context.CancelFunc - store tinykv.KV[instance.State] - provider providers.Provider - instanceStopped chan string + store tinykv.KV[instance.State] + provider providers.Provider + groups map[string][]string } func NewSessionsManager(store tinykv.KV[instance.State], provider providers.Provider) Manager { + ctx, cancel := context.WithCancel(context.Background()) + + groups, err := provider.GetGroups() + if err != nil { + groups = make(map[string][]string) + log.Warn("could not get groups", err) + } + + sm := &SessionsManager{ + ctx: ctx, + cancel: cancel, + store: store, + provider: provider, + groups: groups, + } + + sm.initWatchers() + + return sm +} + +func (sm *SessionsManager) initWatchers() { + updateGroups := make(chan map[string][]string) + go watchGroups(sm.ctx, sm.provider, defaultRefreshFrequency, updateGroups) + go sm.consumeGroups(updateGroups) instanceStopped := make(chan string) + go sm.provider.NotifyInstanceStopped(sm.ctx, instanceStopped) + go sm.consumeInstanceStopped(instanceStopped) +} - go func() { - for instance := range instanceStopped { - // Will delete from the store containers that have been stop either by external sources - // or by the internal expiration loop, if the deleted entry does not exist, it doesn't matter - log.Debugf("received event instance %s is stopped, removing from store", instance) - store.Delete(instance) - } - }() +func (sm *SessionsManager) consumeGroups(receive chan map[string][]string) { + for groups := range receive { + sm.groups = groups + } +} - events, cancel := context.WithCancel(context.Background()) - provider.NotifyInstanceStopped(events, instanceStopped) - - return &SessionsManager{ - events: events, - cancel: cancel, - store: store, - provider: provider, - instanceStopped: instanceStopped, +func (sm *SessionsManager) consumeInstanceStopped(instanceStopped chan string) { + for instance := range instanceStopped { + // Will delete from the store containers that have been stop either by external sources + // or by the internal expiration loop, if the deleted entry does not exist, it doesn't matter + log.Debugf("received event instance %s is stopped, removing from store", instance) + sm.store.Delete(instance) } } @@ -136,6 +162,21 @@ func (s *SessionsManager) RequestSession(names []string, duration time.Duration) return sessionState } +func (s *SessionsManager) RequestSessionGroup(group string, duration time.Duration) (sessionState *SessionState) { + + if len(group) == 0 { + return nil + } + + names := s.groups[group] + + if len(names) == 0 { + return nil + } + + return s.RequestSession(names, duration) +} + func (s *SessionsManager) requestSessionInstance(name string, duration time.Duration) (*instance.State, error) { requestState, exists := s.store.Get(name) @@ -217,6 +258,21 @@ func (s *SessionsManager) RequestReadySession(ctx context.Context, names []strin } } +func (s *SessionsManager) RequestReadySessionGroup(ctx context.Context, group string, duration time.Duration, timeout time.Duration) (sessionState *SessionState, err error) { + + if len(group) == 0 { + return nil, fmt.Errorf("group is mandatory") + } + + names := s.groups[group] + + if len(names) == 0 { + return nil, fmt.Errorf("group has no member") + } + + return s.RequestReadySession(ctx, names, duration, timeout) +} + func (s *SessionsManager) ExpiresAfter(instance *instance.State, duration time.Duration) { s.store.Put(instance.Name, *instance, duration) } @@ -225,9 +281,6 @@ func (s *SessionsManager) Stop() { // Stop event listeners s.cancel() - // Stop receiving stopped instance - close(s.instanceStopped) - // Stop the store s.store.Stop() } diff --git a/docker-compose.yml b/docker-compose.yml index e1ca163..063b6b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,11 @@ services: - traefik.http.middlewares.blocking.plugin.sablier.sablierUrl=http://sablier:10000 - traefik.http.middlewares.blocking.plugin.sablier.sessionDuration=1m - traefik.http.middlewares.blocking.plugin.sablier.blocking.timeout=30s + # Blocking Middleware + - traefik.http.middlewares.blocking.plugin.sablier.group=sablier + - traefik.http.middlewares.blocking.plugin.sablier.sablierUrl=http://sablier:10000 + - traefik.http.middlewares.blocking.plugin.sablier.sessionDuration=1m + - traefik.http.middlewares.blocking.plugin.sablier.blocking.timeout=30s whoami: image: containous/whoami:v1.5.0 @@ -40,3 +45,6 @@ services: # - traefik.enable # - traefik.http.routers.whoami.rule=PathPrefix(`/whoami`) # - traefik.http.routers.whoami.middlewares=dynamic@docker + labels: + - sablier.enable=true + - sablier.group=whoami \ No newline at end of file diff --git a/plugins/nginx/README.md b/plugins/nginx/README.md index 235e605..c5f5a79 100644 --- a/plugins/nginx/README.md +++ b/plugins/nginx/README.md @@ -69,6 +69,7 @@ You can configure the middleware behavior with the following variables: - `set $sablierUrl` The internal routing to reach Sablier API - `set $sablierNames` Comma separated names of containers/services/deployments etc. +- `set $sablierGroup` Group name to use to filter by label, ignored if sablierNames is set - `set $sablierSessionDuration` The session duration after which containers/services/deployments instances are shutdown - `set $sablierNginxInternalRedirect` The internal location for the service to redirect e.g. @nginx diff --git a/plugins/nginx/njs/sablier.js b/plugins/nginx/njs/sablier.js index 3afd4b2..433eaae 100644 --- a/plugins/nginx/njs/sablier.js +++ b/plugins/nginx/njs/sablier.js @@ -22,6 +22,7 @@ function call(r) { * @typedef {Object} SablierConfig * @property {string} sablierUrl * @property {string} names + * @property {string} group * @property {string} sessionDuration * @property {string} internalRedirect * @property {string} displayName @@ -41,6 +42,7 @@ function createConfigurationFromVariables(r) { return { sablierUrl: r.variables.sablierUrl, names: r.variables.sablierNames, + group: r.variables.sablierGroup, sessionDuration: r.variables.sablierSessionDuration, internalRedirect: r.variables.sablierNginxInternalRedirect, diff --git a/plugins/traefik/config.go b/plugins/traefik/config.go index 11af3a7..bf0fa72 100644 --- a/plugins/traefik/config.go +++ b/plugins/traefik/config.go @@ -22,6 +22,7 @@ type BlockingConfiguration struct { type Config struct { SablierURL string `yaml:"sablierUrl"` Names string `yaml:"names"` + Group string `yaml:"group"` SessionDuration string `yaml:"sessionDuration"` splittedNames []string Dynamic *DynamicConfiguration `yaml:"dynamic"` @@ -32,6 +33,7 @@ func CreateConfig() *Config { return &Config{ SablierURL: "http://sablier:10000", Names: "", + Group: "", SessionDuration: "", splittedNames: []string{}, Dynamic: nil, @@ -50,10 +52,12 @@ func (c *Config) BuildRequest(middlewareName string) (*http.Request, error) { names[i] = strings.TrimSpace(names[i]) } - c.splittedNames = names + if len(names) >= 1 && len(names[0]) > 0 { + c.splittedNames = names + } - if len(names) == 0 { - return nil, fmt.Errorf("you must specify at least one name") + if len(names) == 0 && len(c.Group) == 0 { + return nil, fmt.Errorf("you must specify at least one name or a group") } if c.Dynamic != nil && c.Blocking != nil { @@ -94,6 +98,10 @@ func (c *Config) buildDynamicRequest(middlewareName string) (*http.Request, erro q.Add("names", name) } + if c.Group != "" { + q.Add("group", c.Group) + } + if c.Dynamic.DisplayName != "" { q.Add("display_name", c.Dynamic.DisplayName) } else { @@ -150,6 +158,10 @@ func (c *Config) buildBlockingRequest() (*http.Request, error) { q.Add("names", name) } + if c.Group != "" { + q.Add("group", c.Group) + } + if c.Blocking.Timeout != "" { _, err := time.ParseDuration(c.Blocking.Timeout) diff --git a/plugins/traefik/config_test.go b/plugins/traefik/config_test.go index 229fd8d..133c339 100644 --- a/plugins/traefik/config_test.go +++ b/plugins/traefik/config_test.go @@ -16,6 +16,7 @@ func TestConfig_BuildRequest(t *testing.T) { type fields struct { SablierURL string Names string + Group string SessionDuration string Dynamic *traefik.DynamicConfiguration Blocking *traefik.BlockingConfiguration @@ -47,6 +48,17 @@ func TestConfig_BuildRequest(t *testing.T) { want: createRequest("GET", "http://sablier:10000/api/strategies/dynamic?display_name=sablier-middleware&names=nginx&names=apache&session_duration=1m", nil), wantErr: false, }, + { + name: "dynamic session with group", + fields: fields{ + SablierURL: "http://sablier:10000", + Group: "default", + SessionDuration: "1m", + Dynamic: &traefik.DynamicConfiguration{}, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/dynamic?display_name=sablier-middleware&group=default&session_duration=1m", nil), + wantErr: false, + }, { name: "dynamic session with theme values", fields: fields{ @@ -174,6 +186,17 @@ func TestConfig_BuildRequest(t *testing.T) { want: createRequest("GET", "http://sablier:10000/api/strategies/blocking?names=nginx&names=apache&session_duration=1m", nil), wantErr: false, }, + { + name: "blocking session with group", + fields: fields{ + SablierURL: "http://sablier:10000", + Group: "default", + SessionDuration: "1m", + Blocking: &traefik.BlockingConfiguration{}, + }, + want: createRequest("GET", "http://sablier:10000/api/strategies/blocking?group=default&session_duration=1m", nil), + wantErr: false, + }, { name: "blocking session with timeout value", fields: fields{ @@ -218,6 +241,7 @@ func TestConfig_BuildRequest(t *testing.T) { c := &traefik.Config{ SablierURL: tt.fields.SablierURL, Names: tt.fields.Names, + Group: tt.fields.Group, SessionDuration: tt.fields.SessionDuration, Dynamic: tt.fields.Dynamic, Blocking: tt.fields.Blocking,