250 lines
6.3 KiB
Go

// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.
package configcat
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path"
"regexp"
"strings"
"sync"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
)
const (
configCatModule = "gitpod.configcat"
)
var (
DefaultConfig = []byte("{}")
pathRegex = regexp.MustCompile(`^/configcat/configuration-files/gitpod/config_v\d+\.json$`)
)
func init() {
caddy.RegisterModule(ConfigCat{})
httpcaddyfile.RegisterHandlerDirective(configCatModule, parseCaddyfile)
}
type configCache struct {
data []byte
hash string
}
// ConfigCat implements an configcat config CDN
type ConfigCat struct {
sdkKey string
// baseUrl of configcat, default https://cdn-global.configcat.com
baseUrl string
// pollInterval sets after how much time a configuration is considered stale.
pollInterval time.Duration
configCatConfigDir string
configCache map[string]*configCache
m sync.RWMutex
httpClient *http.Client
logger *zap.Logger
}
// CaddyModule returns the Caddy module information.
func (ConfigCat) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.gitpod_configcat",
New: func() caddy.Module { return new(ConfigCat) },
}
}
func (c *ConfigCat) ServeFromFile(w http.ResponseWriter, r *http.Request, fileName string) {
fp := path.Join(c.configCatConfigDir, fileName)
d, err := os.Stat(fp)
if err != nil {
// This should only happen before deploying the FF resource, and logging would not be helpful, hence we can fallback to the default values.
_, _ = w.Write(DefaultConfig)
return
}
requestEtag := r.Header.Get("If-None-Match")
etag := fmt.Sprintf(`W/"%x-%x"`, d.ModTime().Unix(), d.Size())
if requestEtag != "" && requestEtag == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
http.ServeFile(w, r, fp)
}
// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (c *ConfigCat) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
if !pathRegex.MatchString(r.URL.Path) {
return next.ServeHTTP(w, r)
}
arr := strings.Split(r.URL.Path, "/")
configVersion := arr[len(arr)-1]
// ensure that the browser must revalidate it, but still cache it
w.Header().Set("Cache-Control", "no-cache")
if c.configCatConfigDir != "" {
c.ServeFromFile(w, r, configVersion)
return nil
}
w.Header().Set("Content-Type", "application/json")
if c.sdkKey == "" {
_, _ = w.Write(DefaultConfig)
return nil
}
etag := r.Header.Get("If-None-Match")
config := c.getConfigWithCache(configVersion)
if etag != "" && config.hash == etag {
w.WriteHeader(http.StatusNotModified)
return nil
}
if config.hash != "" {
w.Header().Set("ETag", config.hash)
}
w.Write(config.data)
return nil
}
func (c *ConfigCat) Provision(ctx caddy.Context) error {
c.logger = ctx.Logger(c)
c.configCache = make(map[string]*configCache)
c.sdkKey = os.Getenv("CONFIGCAT_SDK_KEY")
c.configCatConfigDir = os.Getenv("CONFIGCAT_DIR")
if c.sdkKey == "" {
return nil
}
if c.configCatConfigDir != "" {
c.logger.Info("serving configcat configuration from local directory")
return nil
}
c.httpClient = &http.Client{
Timeout: 10 * time.Second,
}
c.baseUrl = os.Getenv("CONFIGCAT_BASE_URL")
if c.baseUrl == "" {
c.baseUrl = "https://cdn-global.configcat.com"
}
dur, err := time.ParseDuration(os.Getenv("CONFIGCAT_POLL_INTERVAL"))
if err != nil {
c.pollInterval = time.Minute
c.logger.Warn("cannot parse poll interval of configcat, default to 1m")
} else {
c.pollInterval = dur
}
// poll config
go func() {
for range time.Tick(c.pollInterval) {
for version, cache := range c.configCache {
c.updateConfigCache(version, cache)
}
}
}()
return nil
}
func (c *ConfigCat) getConfigWithCache(configVersion string) *configCache {
c.m.RLock()
data := c.configCache[configVersion]
c.m.RUnlock()
if data != nil {
return data
}
return c.updateConfigCache(configVersion, nil)
}
func (c *ConfigCat) updateConfigCache(version string, prevConfig *configCache) *configCache {
t, err := c.fetchConfigCatConfig(version, prevConfig)
if err != nil {
return &configCache{
data: DefaultConfig,
hash: "",
}
}
c.m.Lock()
c.configCache[version] = t
c.m.Unlock()
return t
}
var sg = &singleflight.Group{}
// fetchConfigCatConfig with different config version. i.e. config_v5.json
func (c *ConfigCat) fetchConfigCatConfig(version string, prevConfig *configCache) (*configCache, error) {
b, err, _ := sg.Do(fmt.Sprintf("fetch_%s", version), func() (interface{}, error) {
url := fmt.Sprintf("%s/configuration-files/%s/%s", c.baseUrl, c.sdkKey, version)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
c.logger.With(zap.Error(err)).Error("cannot create request")
return nil, err
}
if prevConfig != nil && prevConfig.hash != "" {
req.Header.Add("If-None-Match", prevConfig.hash)
}
resp, err := c.httpClient.Do(req)
if err != nil {
c.logger.With(zap.Error(err)).Error("cannot fetch configcat config")
return nil, err
}
if resp.StatusCode == 304 {
return prevConfig, nil
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
c.logger.With(zap.Error(err), zap.String("version", version)).Error("cannot read configcat config response")
return nil, err
}
return &configCache{
data: b,
hash: resp.Header.Get("Etag"),
}, nil
}
return nil, fmt.Errorf("received unexpected response %v", resp.Status)
})
if err != nil {
return nil, err
}
return b.(*configCache), nil
}
// UnmarshalCaddyfile implements Caddyfile.Unmarshaler.
func (m *ConfigCat) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
m := new(ConfigCat)
err := m.UnmarshalCaddyfile(h.Dispenser)
if err != nil {
return nil, err
}
return m, nil
}
// Interface guards
var (
_ caddyhttp.MiddlewareHandler = (*ConfigCat)(nil)
_ caddyfile.Unmarshaler = (*ConfigCat)(nil)
)