Path: blob/main/components/proxy/plugins/configcat/configcat.go
2500 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package configcat56import (7"fmt"8"io/ioutil"9"net/http"10"os"11"path"12"regexp"13"strings"14"sync"15"time"1617"github.com/caddyserver/caddy/v2"18"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"19"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"20"github.com/caddyserver/caddy/v2/modules/caddyhttp"21"go.uber.org/zap"22"golang.org/x/sync/singleflight"23)2425const (26configCatModule = "gitpod.configcat"27)2829var (30DefaultConfig = []byte("{}")31pathRegex = regexp.MustCompile(`^/configcat/configuration-files/gitpod/config_v\d+\.json$`)32)3334func init() {35caddy.RegisterModule(ConfigCat{})36httpcaddyfile.RegisterHandlerDirective(configCatModule, parseCaddyfile)37}3839type configCache struct {40data []byte41hash string42}4344// ConfigCat implements an configcat config CDN45type ConfigCat struct {46sdkKey string47// baseUrl of configcat, default https://cdn-global.configcat.com48baseUrl string49// pollInterval sets after how much time a configuration is considered stale.50pollInterval time.Duration5152configCatConfigDir string5354configCache map[string]*configCache55m sync.RWMutex5657httpClient *http.Client58logger *zap.Logger59}6061// CaddyModule returns the Caddy module information.62func (ConfigCat) CaddyModule() caddy.ModuleInfo {63return caddy.ModuleInfo{64ID: "http.handlers.gitpod_configcat",65New: func() caddy.Module { return new(ConfigCat) },66}67}6869func (c *ConfigCat) ServeFromFile(w http.ResponseWriter, r *http.Request, fileName string) {70fp := path.Join(c.configCatConfigDir, fileName)71d, err := os.Stat(fp)72if err != nil {73// This should only happen before deploying the FF resource, and logging would not be helpful, hence we can fallback to the default values.74_, _ = w.Write(DefaultConfig)75return76}77requestEtag := r.Header.Get("If-None-Match")78etag := fmt.Sprintf(`W/"%x-%x"`, d.ModTime().Unix(), d.Size())79if requestEtag != "" && requestEtag == etag {80w.WriteHeader(http.StatusNotModified)81return82}83w.Header().Set("ETag", etag)84http.ServeFile(w, r, fp)85}8687// ServeHTTP implements caddyhttp.MiddlewareHandler.88func (c *ConfigCat) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {89if !pathRegex.MatchString(r.URL.Path) {90return next.ServeHTTP(w, r)91}92arr := strings.Split(r.URL.Path, "/")93configVersion := arr[len(arr)-1]9495// ensure that the browser must revalidate it, but still cache it96w.Header().Set("Cache-Control", "no-cache")9798if c.configCatConfigDir != "" {99c.ServeFromFile(w, r, configVersion)100return nil101}102103w.Header().Set("Content-Type", "application/json")104if c.sdkKey == "" {105_, _ = w.Write(DefaultConfig)106return nil107}108etag := r.Header.Get("If-None-Match")109110config := c.getConfigWithCache(configVersion)111if etag != "" && config.hash == etag {112w.WriteHeader(http.StatusNotModified)113return nil114}115if config.hash != "" {116w.Header().Set("ETag", config.hash)117}118w.Write(config.data)119return nil120}121122func (c *ConfigCat) Provision(ctx caddy.Context) error {123c.logger = ctx.Logger(c)124c.configCache = make(map[string]*configCache)125126c.sdkKey = os.Getenv("CONFIGCAT_SDK_KEY")127c.configCatConfigDir = os.Getenv("CONFIGCAT_DIR")128if c.sdkKey == "" {129return nil130}131if c.configCatConfigDir != "" {132c.logger.Info("serving configcat configuration from local directory")133return nil134}135136c.httpClient = &http.Client{137Timeout: 10 * time.Second,138}139c.baseUrl = os.Getenv("CONFIGCAT_BASE_URL")140if c.baseUrl == "" {141c.baseUrl = "https://cdn-global.configcat.com"142}143dur, err := time.ParseDuration(os.Getenv("CONFIGCAT_POLL_INTERVAL"))144if err != nil {145c.pollInterval = time.Minute146c.logger.Warn("cannot parse poll interval of configcat, default to 1m")147} else {148c.pollInterval = dur149}150151// poll config152go func() {153for range time.Tick(c.pollInterval) {154for version, cache := range c.configCache {155c.updateConfigCache(version, cache)156}157}158}()159return nil160}161162func (c *ConfigCat) getConfigWithCache(configVersion string) *configCache {163c.m.RLock()164data := c.configCache[configVersion]165c.m.RUnlock()166if data != nil {167return data168}169return c.updateConfigCache(configVersion, nil)170}171172func (c *ConfigCat) updateConfigCache(version string, prevConfig *configCache) *configCache {173t, err := c.fetchConfigCatConfig(version, prevConfig)174if err != nil {175return &configCache{176data: DefaultConfig,177hash: "",178}179}180c.m.Lock()181c.configCache[version] = t182c.m.Unlock()183return t184}185186var sg = &singleflight.Group{}187188// fetchConfigCatConfig with different config version. i.e. config_v5.json189func (c *ConfigCat) fetchConfigCatConfig(version string, prevConfig *configCache) (*configCache, error) {190b, err, _ := sg.Do(fmt.Sprintf("fetch_%s", version), func() (interface{}, error) {191url := fmt.Sprintf("%s/configuration-files/%s/%s", c.baseUrl, c.sdkKey, version)192req, err := http.NewRequest("GET", url, nil)193if err != nil {194c.logger.With(zap.Error(err)).Error("cannot create request")195return nil, err196}197if prevConfig != nil && prevConfig.hash != "" {198req.Header.Add("If-None-Match", prevConfig.hash)199}200resp, err := c.httpClient.Do(req)201if err != nil {202c.logger.With(zap.Error(err)).Error("cannot fetch configcat config")203return nil, err204}205206if resp.StatusCode == 304 {207return prevConfig, nil208}209210if resp.StatusCode >= 200 && resp.StatusCode < 300 {211b, err := ioutil.ReadAll(resp.Body)212if err != nil {213c.logger.With(zap.Error(err), zap.String("version", version)).Error("cannot read configcat config response")214return nil, err215}216return &configCache{217data: b,218hash: resp.Header.Get("Etag"),219}, nil220}221return nil, fmt.Errorf("received unexpected response %v", resp.Status)222})223if err != nil {224return nil, err225}226return b.(*configCache), nil227}228229// UnmarshalCaddyfile implements Caddyfile.Unmarshaler.230func (m *ConfigCat) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {231return nil232}233234func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {235m := new(ConfigCat)236err := m.UnmarshalCaddyfile(h.Dispenser)237if err != nil {238return nil, err239}240241return m, nil242}243244// Interface guards245var (246_ caddyhttp.MiddlewareHandler = (*ConfigCat)(nil)247_ caddyfile.Unmarshaler = (*ConfigCat)(nil)248)249250251