Skip to content

Commit 5f9f15a

Browse files
committed
Add experiments detail API & tests
Signed-off-by: Danny Kopping <danny@coder.com>
1 parent d9da054 commit 5f9f15a

File tree

4 files changed

+208
-4
lines changed

4 files changed

+208
-4
lines changed

coderd/coderd.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ func New(options *Options) *API {
754754
r.Route("/experiments", func(r chi.Router) {
755755
r.Use(apiKeyMiddleware)
756756
r.Get("/available", handleExperimentsSafe)
757+
r.Get("/detail", api.handleExperimentsDetail)
757758
r.Get("/", api.handleExperimentsGet)
758759
})
759760
r.Get("/updatecheck", api.updateCheck)
@@ -1455,7 +1456,7 @@ func ReadExperiments(log slog.Logger, raw []string) codersdk.Experiments {
14551456
exps := make([]codersdk.Experiment, 0, len(raw))
14561457
for _, v := range raw {
14571458
switch v {
1458-
case "*":
1459+
case codersdk.ExperimentsAllWildcard:
14591460
exps = append(exps, codersdk.ExperimentsAll...)
14601461
default:
14611462
ex := codersdk.Experiment(strings.ToLower(v))

coderd/experiments.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,15 @@ func handleExperimentsSafe(rw http.ResponseWriter, r *http.Request) {
3232
Safe: codersdk.ExperimentsAll,
3333
})
3434
}
35+
36+
// @Summary Get experiments' details
37+
// @ID get-experiments-details
38+
// @Security CoderSessionToken
39+
// @Produce json
40+
// @Tags General
41+
// @Success 200 {array} codersdk.ExperimentDetail
42+
// @Router /experiments/detail [get]
43+
func (api *API) handleExperimentsDetail(rw http.ResponseWriter, r *http.Request) {
44+
ctx := r.Context()
45+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExperimentDetails(api.Experiments))
46+
}

coderd/experiments_test.go

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func Test_Experiments(t *testing.T) {
5757
t.Run("wildcard", func(t *testing.T) {
5858
t.Parallel()
5959
cfg := coderdtest.DeploymentValues(t)
60-
cfg.Experiments = []string{"*"}
60+
cfg.Experiments = []string{codersdk.ExperimentsAllWildcard}
6161
client := coderdtest.New(t, &coderdtest.Options{
6262
DeploymentValues: cfg,
6363
})
@@ -79,7 +79,7 @@ func Test_Experiments(t *testing.T) {
7979
t.Run("alternate wildcard with manual opt-in", func(t *testing.T) {
8080
t.Parallel()
8181
cfg := coderdtest.DeploymentValues(t)
82-
cfg.Experiments = []string{"*", "dAnGeR"}
82+
cfg.Experiments = []string{codersdk.ExperimentsAllWildcard, "dAnGeR"}
8383
client := coderdtest.New(t, &coderdtest.Options{
8484
DeploymentValues: cfg,
8585
})
@@ -102,7 +102,7 @@ func Test_Experiments(t *testing.T) {
102102
t.Run("Unauthorized", func(t *testing.T) {
103103
t.Parallel()
104104
cfg := coderdtest.DeploymentValues(t)
105-
cfg.Experiments = []string{"*"}
105+
cfg.Experiments = []string{codersdk.ExperimentsAllWildcard}
106106
client := coderdtest.New(t, &coderdtest.Options{
107107
DeploymentValues: cfg,
108108
})
@@ -133,4 +133,140 @@ func Test_Experiments(t *testing.T) {
133133
require.NotNil(t, experiments)
134134
require.ElementsMatch(t, codersdk.ExperimentsAll, experiments.Safe)
135135
})
136+
137+
t.Run("experiments detail", func(t *testing.T) {
138+
t.Parallel()
139+
140+
const (
141+
invalidExp = "bob"
142+
expiredExp = "auto-fill-parameters" // using a string here not a constant since this experiment has expired & will be deleted eventually
143+
)
144+
145+
tests := []struct {
146+
name string
147+
enabledValid []codersdk.Experiment
148+
enabledInvalid []codersdk.Experiment
149+
expectedExtraCount int
150+
}{
151+
{
152+
name: "using defaults",
153+
},
154+
{
155+
name: "use all (*)",
156+
enabledValid: []codersdk.Experiment{codersdk.Experiment(codersdk.ExperimentsAllWildcard)},
157+
},
158+
{
159+
name: "only valid experiments",
160+
enabledValid: codersdk.ExperimentsAll,
161+
},
162+
{
163+
name: "use all (*) + invalid",
164+
enabledValid: []codersdk.Experiment{codersdk.Experiment(codersdk.ExperimentsAllWildcard), codersdk.Experiment(expiredExp)},
165+
expectedExtraCount: 1,
166+
},
167+
{
168+
name: "valid + expired experiments",
169+
enabledValid: codersdk.ExperimentsAll,
170+
enabledInvalid: []codersdk.Experiment{codersdk.Experiment(expiredExp)},
171+
expectedExtraCount: 1,
172+
},
173+
{
174+
name: "valid + expired + invalid experiments",
175+
enabledValid: codersdk.ExperimentsAll,
176+
enabledInvalid: []codersdk.Experiment{codersdk.Experiment(invalidExp), codersdk.Experiment(expiredExp)},
177+
expectedExtraCount: 2,
178+
},
179+
{
180+
name: "only expired",
181+
enabledInvalid: []codersdk.Experiment{codersdk.Experiment(expiredExp)},
182+
expectedExtraCount: 1,
183+
},
184+
{
185+
name: "only invalid",
186+
enabledInvalid: []codersdk.Experiment{codersdk.Experiment(invalidExp)},
187+
expectedExtraCount: 1,
188+
},
189+
{
190+
name: "expired + invalid experiments",
191+
enabledInvalid: []codersdk.Experiment{codersdk.Experiment(invalidExp), codersdk.Experiment(expiredExp)},
192+
expectedExtraCount: 2,
193+
},
194+
}
195+
196+
for _, tc := range tests {
197+
tc := tc
198+
199+
t.Run(tc.name, func(t *testing.T) {
200+
t.Parallel()
201+
202+
var exps []string
203+
204+
// given
205+
for _, e := range tc.enabledValid {
206+
exps = append(exps, string(e))
207+
}
208+
for _, e := range tc.enabledInvalid {
209+
exps = append(exps, string(e))
210+
}
211+
212+
cfg := coderdtest.DeploymentValues(t)
213+
cfg.Experiments = exps
214+
client := coderdtest.New(t, &coderdtest.Options{
215+
DeploymentValues: cfg,
216+
})
217+
_ = coderdtest.CreateFirstUser(t, client)
218+
219+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
220+
defer cancel()
221+
222+
// when
223+
experiments, err := client.ExperimentDetails(ctx)
224+
225+
// then
226+
require.NoError(t, err)
227+
require.Len(t, experiments, len(codersdk.ExperimentsAll)+tc.expectedExtraCount)
228+
require.Conditionf(t, func() (success bool) {
229+
var enabled []bool
230+
231+
var validCount int
232+
for _, exp := range tc.enabledValid {
233+
// don't count wildcard experiment itself as a single experiment
234+
if exp == codersdk.ExperimentsAllWildcard {
235+
validCount += len(codersdk.ExperimentsAll)
236+
} else {
237+
validCount++
238+
}
239+
}
240+
241+
for _, exp := range append(tc.enabledValid, tc.enabledInvalid...) {
242+
for _, e := range experiments {
243+
// * is special-cased to mean all experiments
244+
if (exp == codersdk.ExperimentsAllWildcard || e.Name == exp) && e.Enabled {
245+
// codersdk.ExperimentsAllWildcard cannot include invalid experiments
246+
if exp == codersdk.ExperimentsAllWildcard && e.Invalid {
247+
continue
248+
}
249+
250+
enabled = append(enabled, true)
251+
}
252+
}
253+
}
254+
255+
return len(enabled) == validCount+len(tc.enabledInvalid)
256+
}, "enabled experiment(s) were either not found or not marked as enabled")
257+
require.Conditionf(t, func() (success bool) {
258+
var invalid []bool
259+
for _, exp := range tc.enabledInvalid {
260+
for _, e := range experiments {
261+
if e.Name == exp && e.Invalid {
262+
invalid = append(invalid, true)
263+
}
264+
}
265+
}
266+
267+
return len(invalid) == len(tc.enabledInvalid)
268+
}, "invalid experiment(s) were either not found or not marked as invalid")
269+
})
270+
}
271+
})
136272
}

codersdk/deployment.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2178,6 +2178,12 @@ func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
21782178

21792179
type Experiment string
21802180

2181+
type ExperimentDetail struct {
2182+
Name Experiment `json:"name"`
2183+
Enabled bool `json:"enabled"`
2184+
Invalid bool `json:"invalid"`
2185+
}
2186+
21812187
const (
21822188
// Add new experiments here!
21832189
ExperimentExample Experiment = "example" // This isn't used for anything.
@@ -2193,6 +2199,8 @@ var ExperimentsAll = Experiments{
21932199
ExperimentSharedPorts,
21942200
}
21952201

2202+
const ExperimentsAllWildcard = "*"
2203+
21962204
// Experiments is a list of experiments.
21972205
// Multiple experiments may be enabled at the same time.
21982206
// Experiments are not safe for production use, and are not guaranteed to
@@ -2209,6 +2217,39 @@ func (e Experiments) Enabled(ex Experiment) bool {
22092217
return false
22102218
}
22112219

2220+
// ExperimentDetails returns a list of all experiments, including those enabled via configuration options, and returns
2221+
// details for each experiment about whether they are active or invalid.
2222+
// Unknown experiments are indistinguishable from removed experiments, so we treat them the same way (as "invalid").
2223+
func ExperimentDetails(exps Experiments) []ExperimentDetail {
2224+
invalid := make(map[Experiment]bool, len(exps))
2225+
enabled := make(map[Experiment]struct{}, len(exps))
2226+
2227+
// ExperimentsAll gives us all safe experiments, which we mark as not invalid
2228+
for _, e := range ExperimentsAll {
2229+
invalid[e] = false
2230+
}
2231+
2232+
// given experiments might not be included in the list of safe experiments, and are therefore marked invalid
2233+
for _, e := range exps {
2234+
enabled[e] = struct{}{}
2235+
2236+
if _, found := invalid[e]; found {
2237+
// already known to be safe, can be skipped
2238+
continue
2239+
}
2240+
2241+
invalid[e] = true
2242+
}
2243+
2244+
out := make([]ExperimentDetail, 0, len(invalid))
2245+
for e, expired := range invalid {
2246+
_, found := enabled[e]
2247+
out = append(out, ExperimentDetail{Name: e, Invalid: expired, Enabled: found})
2248+
}
2249+
2250+
return out
2251+
}
2252+
22122253
func (c *Client) Experiments(ctx context.Context) (Experiments, error) {
22132254
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments", nil)
22142255
if err != nil {
@@ -2241,6 +2282,20 @@ func (c *Client) SafeExperiments(ctx context.Context) (AvailableExperiments, err
22412282
return exp, json.NewDecoder(res.Body).Decode(&exp)
22422283
}
22432284

2285+
func (c *Client) ExperimentDetails(ctx context.Context) ([]ExperimentDetail, error) {
2286+
var exp []ExperimentDetail
2287+
2288+
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments/detail", nil)
2289+
if err != nil {
2290+
return exp, err
2291+
}
2292+
defer res.Body.Close()
2293+
if res.StatusCode != http.StatusOK {
2294+
return exp, ReadBodyAsError(res)
2295+
}
2296+
return exp, json.NewDecoder(res.Body).Decode(&exp)
2297+
}
2298+
22442299
type DAUsResponse struct {
22452300
Entries []DAUEntry `json:"entries"`
22462301
TZHourOffset int `json:"tz_hour_offset"`

0 commit comments

Comments
 (0)