feat: add --experiments flag to replace --experimental (#5767)

- Deprecates the --experimental flag
- Adds a new flag --experiments which supports passing multiple comma-separated values or a wildcard value.
- Exposes a new endpoint /api/v2/experiments that returns the list of enabled experiments.
- Deprecates the field Features.Experimental in favour of this new API.
- Updates apidocgen to support type aliases (shoutout to @mtojek).
- Modifies apitypings to support generating slice types.
- Updates develop.sh to pass additional args after -- to $CODERD_SHIM.
This commit is contained in:
Cian Johnston
2023-01-18 19:12:53 +00:00
committed by GitHub
parent 47c3d72294
commit 56b996532f
29 changed files with 593 additions and 41 deletions
+47 -1
View File
@@ -387,6 +387,34 @@ const docTemplate = `{
}
}
},
"/experiments": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"General"
],
"summary": "Get experiments",
"operationId": "get-experiments",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Experiment"
}
}
}
}
}
},
"/files": {
"post": {
"security": [
@@ -5740,7 +5768,15 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.DERP"
},
"experimental": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
"description": "DEPRECATED: Use Experiments instead.",
"allOf": [
{
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
}
]
},
"experiments": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-array_string"
},
"gitauth": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-array_codersdk_GitAuthConfig"
@@ -6043,6 +6079,7 @@ const docTemplate = `{
}
},
"experimental": {
"description": "DEPRECATED: use Experiments instead.",
"type": "boolean"
},
"features": {
@@ -6065,6 +6102,15 @@ const docTemplate = `{
}
}
},
"codersdk.Experiment": {
"type": "string",
"enum": [
"vscode_local"
],
"x-enum-varnames": [
"ExperimentVSCodeLocal"
]
},
"codersdk.Feature": {
"type": "object",
"properties": {
+39 -1
View File
@@ -329,6 +329,30 @@
}
}
},
"/experiments": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["General"],
"summary": "Get experiments",
"operationId": "get-experiments",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Experiment"
}
}
}
}
}
},
"/files": {
"post": {
"security": [
@@ -5093,7 +5117,15 @@
"$ref": "#/definitions/codersdk.DERP"
},
"experimental": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
"description": "DEPRECATED: Use Experiments instead.",
"allOf": [
{
"$ref": "#/definitions/codersdk.DeploymentConfigField-bool"
}
]
},
"experiments": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-array_string"
},
"gitauth": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-array_codersdk_GitAuthConfig"
@@ -5392,6 +5424,7 @@
}
},
"experimental": {
"description": "DEPRECATED: use Experiments instead.",
"type": "boolean"
},
"features": {
@@ -5414,6 +5447,11 @@
}
}
},
"codersdk.Experiment": {
"type": "string",
"enum": ["vscode_local"],
"x-enum-varnames": ["ExperimentVSCodeLocal"]
},
"codersdk.Feature": {
"type": "object",
"properties": {
+35
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
@@ -52,6 +53,7 @@ import (
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/coderd/util/slice"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionerd/proto"
@@ -220,6 +222,7 @@ func New(options *Options) *API {
},
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
Experiments: initExperiments(options.Logger, options.DeploymentConfig.Experiments.Value, options.DeploymentConfig.Experimental.Value),
}
if options.UpdateCheckOptions != nil {
api.updateChecker = updatecheck.New(
@@ -348,6 +351,10 @@ func New(options *Options) *API {
r.Post("/csp/reports", api.logReportCSPViolations)
r.Get("/buildinfo", buildInfo)
r.Route("/experiments", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/", api.handleExperimentsGet)
})
r.Get("/updatecheck", api.updateCheck)
r.Route("/config", func(r chi.Router) {
r.Use(apiKeyMiddleware)
@@ -646,6 +653,10 @@ type API struct {
metricsCache *metricscache.Cache
workspaceAgentCache *wsconncache.Cache
updateChecker *updatecheck.Checker
// Experiments contains the list of experiments currently enabled.
// This is used to gate features that are not yet ready for production.
Experiments codersdk.Experiments
}
// Close waits for all WebSocket connections to drain before returning.
@@ -752,3 +763,27 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti
return proto.NewDRPCProvisionerDaemonClient(clientSession), nil
}
// nolint:revive
func initExperiments(log slog.Logger, raw []string, legacyAll bool) codersdk.Experiments {
exps := make([]codersdk.Experiment, 0, len(raw))
for _, v := range raw {
switch v {
case "*":
exps = append(exps, codersdk.ExperimentsAll...)
default:
ex := codersdk.Experiment(strings.ToLower(v))
if !slice.Contains(codersdk.ExperimentsAll, ex) {
log.Warn(context.Background(), "🐉 HERE BE DRAGONS: opting into hidden experiment", slog.F("experiment", ex))
}
exps = append(exps, ex)
}
}
// --experiments takes precedence over --experimental. It's deprecated.
if legacyAll && len(raw) == 0 {
log.Warn(context.Background(), "--experimental is deprecated, use --experiments='*' instead")
exps = append(exps, codersdk.ExperimentsAll...)
}
return exps
}
+1
View File
@@ -48,6 +48,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"GET:/healthz": {NoAuthorize: true},
"GET:/api/v2": {NoAuthorize: true},
"GET:/api/v2/buildinfo": {NoAuthorize: true},
"GET:/api/v2/experiments": {NoAuthorize: true}, // This route requires AuthN, but not AuthZ.
"GET:/api/v2/updatecheck": {NoAuthorize: true},
"GET:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/first": {NoAuthorize: true},
-1
View File
@@ -85,7 +85,6 @@ type Options struct {
AppHostname string
AWSCertificates awsidentity.Certificates
Authorizer rbac.Authorizer
Experimental bool
AzureCertificates x509.VerifyOptions
GithubOAuth2Config *coderd.GithubOAuth2Config
RealIPConfig *httpmw.RealIPConfig
+19
View File
@@ -0,0 +1,19 @@
package coderd
import (
"net/http"
"github.com/coder/coder/coderd/httpapi"
)
// @Summary Get experiments
// @ID get-experiments
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Success 200 {array} codersdk.Experiment
// @Router /experiments [get]
func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
httpapi.Write(ctx, rw, http.StatusOK, api.Experiments)
}
+142
View File
@@ -0,0 +1,142 @@
package coderd_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
func Test_Experiments(t *testing.T) {
t.Parallel()
t.Run("empty", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentConfig(t)
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: cfg,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
experiments, err := client.Experiments(ctx)
require.NoError(t, err)
require.NotNil(t, experiments)
require.Empty(t, experiments)
require.False(t, experiments.Enabled(codersdk.ExperimentVSCodeLocal))
require.False(t, experiments.Enabled("foo"))
})
t.Run("multiple features", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentConfig(t)
cfg.Experiments.Value = []string{"foo", "BAR"}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: cfg,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
experiments, err := client.Experiments(ctx)
require.NoError(t, err)
require.NotNil(t, experiments)
// Should be lower-cased.
require.ElementsMatch(t, []codersdk.Experiment{"foo", "bar"}, experiments)
require.True(t, experiments.Enabled("foo"))
require.True(t, experiments.Enabled("bar"))
require.False(t, experiments.Enabled("baz"))
})
t.Run("wildcard", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentConfig(t)
cfg.Experiments.Value = []string{"*"}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: cfg,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
experiments, err := client.Experiments(ctx)
require.NoError(t, err)
require.NotNil(t, experiments)
require.ElementsMatch(t, codersdk.ExperimentsAll, experiments)
for _, ex := range codersdk.ExperimentsAll {
require.True(t, experiments.Enabled(ex))
}
require.False(t, experiments.Enabled("danger"))
})
t.Run("alternate wildcard with manual opt-in", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentConfig(t)
cfg.Experiments.Value = []string{"*", "dAnGeR"}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: cfg,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
experiments, err := client.Experiments(ctx)
require.NoError(t, err)
require.NotNil(t, experiments)
require.ElementsMatch(t, append(codersdk.ExperimentsAll, "danger"), experiments)
for _, ex := range codersdk.ExperimentsAll {
require.True(t, experiments.Enabled(ex))
}
require.True(t, experiments.Enabled("danger"))
require.False(t, experiments.Enabled("herebedragons"))
})
t.Run("legacy wildcard", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentConfig(t)
cfg.Experimental.Value = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: cfg,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
experiments, err := client.Experiments(ctx)
require.NoError(t, err)
require.NotNil(t, experiments)
require.ElementsMatch(t, codersdk.ExperimentsAll, experiments)
for _, ex := range codersdk.ExperimentsAll {
require.True(t, experiments.Enabled(ex))
}
require.False(t, experiments.Enabled("danger"))
})
t.Run("Unauthorized", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentConfig(t)
cfg.Experiments.Value = []string{"*"}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: cfg,
})
// Explicitly omit creating a user so we're unauthorized.
// _ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.Experiments(ctx)
require.Error(t, err)
require.ErrorContains(t, err, httpmw.SignedOutErrorMessage)
})
}