mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
Generated
+47
-1
@@ -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": {
|
||||
|
||||
Generated
+39
-1
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user