mirror of
https://github.com/gitpod-io/gitpod.git
synced 2025-12-08 17:36:30 +00:00
250 lines
6.3 KiB
Go
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)
|
|
)
|