Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/proxy/plugins/configcat/configcat.go
2500 views
1
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package configcat
6
7
import (
8
"fmt"
9
"io/ioutil"
10
"net/http"
11
"os"
12
"path"
13
"regexp"
14
"strings"
15
"sync"
16
"time"
17
18
"github.com/caddyserver/caddy/v2"
19
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
20
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
21
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
22
"go.uber.org/zap"
23
"golang.org/x/sync/singleflight"
24
)
25
26
const (
27
configCatModule = "gitpod.configcat"
28
)
29
30
var (
31
DefaultConfig = []byte("{}")
32
pathRegex = regexp.MustCompile(`^/configcat/configuration-files/gitpod/config_v\d+\.json$`)
33
)
34
35
func init() {
36
caddy.RegisterModule(ConfigCat{})
37
httpcaddyfile.RegisterHandlerDirective(configCatModule, parseCaddyfile)
38
}
39
40
type configCache struct {
41
data []byte
42
hash string
43
}
44
45
// ConfigCat implements an configcat config CDN
46
type ConfigCat struct {
47
sdkKey string
48
// baseUrl of configcat, default https://cdn-global.configcat.com
49
baseUrl string
50
// pollInterval sets after how much time a configuration is considered stale.
51
pollInterval time.Duration
52
53
configCatConfigDir string
54
55
configCache map[string]*configCache
56
m sync.RWMutex
57
58
httpClient *http.Client
59
logger *zap.Logger
60
}
61
62
// CaddyModule returns the Caddy module information.
63
func (ConfigCat) CaddyModule() caddy.ModuleInfo {
64
return caddy.ModuleInfo{
65
ID: "http.handlers.gitpod_configcat",
66
New: func() caddy.Module { return new(ConfigCat) },
67
}
68
}
69
70
func (c *ConfigCat) ServeFromFile(w http.ResponseWriter, r *http.Request, fileName string) {
71
fp := path.Join(c.configCatConfigDir, fileName)
72
d, err := os.Stat(fp)
73
if err != nil {
74
// This should only happen before deploying the FF resource, and logging would not be helpful, hence we can fallback to the default values.
75
_, _ = w.Write(DefaultConfig)
76
return
77
}
78
requestEtag := r.Header.Get("If-None-Match")
79
etag := fmt.Sprintf(`W/"%x-%x"`, d.ModTime().Unix(), d.Size())
80
if requestEtag != "" && requestEtag == etag {
81
w.WriteHeader(http.StatusNotModified)
82
return
83
}
84
w.Header().Set("ETag", etag)
85
http.ServeFile(w, r, fp)
86
}
87
88
// ServeHTTP implements caddyhttp.MiddlewareHandler.
89
func (c *ConfigCat) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
90
if !pathRegex.MatchString(r.URL.Path) {
91
return next.ServeHTTP(w, r)
92
}
93
arr := strings.Split(r.URL.Path, "/")
94
configVersion := arr[len(arr)-1]
95
96
// ensure that the browser must revalidate it, but still cache it
97
w.Header().Set("Cache-Control", "no-cache")
98
99
if c.configCatConfigDir != "" {
100
c.ServeFromFile(w, r, configVersion)
101
return nil
102
}
103
104
w.Header().Set("Content-Type", "application/json")
105
if c.sdkKey == "" {
106
_, _ = w.Write(DefaultConfig)
107
return nil
108
}
109
etag := r.Header.Get("If-None-Match")
110
111
config := c.getConfigWithCache(configVersion)
112
if etag != "" && config.hash == etag {
113
w.WriteHeader(http.StatusNotModified)
114
return nil
115
}
116
if config.hash != "" {
117
w.Header().Set("ETag", config.hash)
118
}
119
w.Write(config.data)
120
return nil
121
}
122
123
func (c *ConfigCat) Provision(ctx caddy.Context) error {
124
c.logger = ctx.Logger(c)
125
c.configCache = make(map[string]*configCache)
126
127
c.sdkKey = os.Getenv("CONFIGCAT_SDK_KEY")
128
c.configCatConfigDir = os.Getenv("CONFIGCAT_DIR")
129
if c.sdkKey == "" {
130
return nil
131
}
132
if c.configCatConfigDir != "" {
133
c.logger.Info("serving configcat configuration from local directory")
134
return nil
135
}
136
137
c.httpClient = &http.Client{
138
Timeout: 10 * time.Second,
139
}
140
c.baseUrl = os.Getenv("CONFIGCAT_BASE_URL")
141
if c.baseUrl == "" {
142
c.baseUrl = "https://cdn-global.configcat.com"
143
}
144
dur, err := time.ParseDuration(os.Getenv("CONFIGCAT_POLL_INTERVAL"))
145
if err != nil {
146
c.pollInterval = time.Minute
147
c.logger.Warn("cannot parse poll interval of configcat, default to 1m")
148
} else {
149
c.pollInterval = dur
150
}
151
152
// poll config
153
go func() {
154
for range time.Tick(c.pollInterval) {
155
for version, cache := range c.configCache {
156
c.updateConfigCache(version, cache)
157
}
158
}
159
}()
160
return nil
161
}
162
163
func (c *ConfigCat) getConfigWithCache(configVersion string) *configCache {
164
c.m.RLock()
165
data := c.configCache[configVersion]
166
c.m.RUnlock()
167
if data != nil {
168
return data
169
}
170
return c.updateConfigCache(configVersion, nil)
171
}
172
173
func (c *ConfigCat) updateConfigCache(version string, prevConfig *configCache) *configCache {
174
t, err := c.fetchConfigCatConfig(version, prevConfig)
175
if err != nil {
176
return &configCache{
177
data: DefaultConfig,
178
hash: "",
179
}
180
}
181
c.m.Lock()
182
c.configCache[version] = t
183
c.m.Unlock()
184
return t
185
}
186
187
var sg = &singleflight.Group{}
188
189
// fetchConfigCatConfig with different config version. i.e. config_v5.json
190
func (c *ConfigCat) fetchConfigCatConfig(version string, prevConfig *configCache) (*configCache, error) {
191
b, err, _ := sg.Do(fmt.Sprintf("fetch_%s", version), func() (interface{}, error) {
192
url := fmt.Sprintf("%s/configuration-files/%s/%s", c.baseUrl, c.sdkKey, version)
193
req, err := http.NewRequest("GET", url, nil)
194
if err != nil {
195
c.logger.With(zap.Error(err)).Error("cannot create request")
196
return nil, err
197
}
198
if prevConfig != nil && prevConfig.hash != "" {
199
req.Header.Add("If-None-Match", prevConfig.hash)
200
}
201
resp, err := c.httpClient.Do(req)
202
if err != nil {
203
c.logger.With(zap.Error(err)).Error("cannot fetch configcat config")
204
return nil, err
205
}
206
207
if resp.StatusCode == 304 {
208
return prevConfig, nil
209
}
210
211
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
212
b, err := ioutil.ReadAll(resp.Body)
213
if err != nil {
214
c.logger.With(zap.Error(err), zap.String("version", version)).Error("cannot read configcat config response")
215
return nil, err
216
}
217
return &configCache{
218
data: b,
219
hash: resp.Header.Get("Etag"),
220
}, nil
221
}
222
return nil, fmt.Errorf("received unexpected response %v", resp.Status)
223
})
224
if err != nil {
225
return nil, err
226
}
227
return b.(*configCache), nil
228
}
229
230
// UnmarshalCaddyfile implements Caddyfile.Unmarshaler.
231
func (m *ConfigCat) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
232
return nil
233
}
234
235
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
236
m := new(ConfigCat)
237
err := m.UnmarshalCaddyfile(h.Dispenser)
238
if err != nil {
239
return nil, err
240
}
241
242
return m, nil
243
}
244
245
// Interface guards
246
var (
247
_ caddyhttp.MiddlewareHandler = (*ConfigCat)(nil)
248
_ caddyfile.Unmarshaler = (*ConfigCat)(nil)
249
)
250
251