diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 02b24f371ce6f..3a36218277c7e 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -446,10 +446,19 @@ func newConfig() *codersdk.DeploymentConfig { Default: 512, }, }, + // DEPRECATED: use Experiments instead. Experimental: &codersdk.DeploymentConfigField[bool]{ - Name: "Experimental", - Usage: "Enable experimental features. Experimental features are not ready for production.", - Flag: "experimental", + Name: "Experimental", + Usage: "Enable experimental features. Experimental features are not ready for production.", + Flag: "experimental", + Default: false, + Hidden: true, + }, + Experiments: &codersdk.DeploymentConfigField[[]string]{ + Name: "Experiments", + Usage: "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.", + Flag: "experiments", + Default: []string{}, }, UpdateCheck: &codersdk.DeploymentConfigField[bool]{ Name: "Update Check", @@ -557,12 +566,12 @@ func setConfig(prefix string, vip *viper.Viper, target interface{}) { // with a comma, but Viper only supports with a space. This // is a small hack around it! rawSlice := reflect.ValueOf(vip.GetStringSlice(prefix)).Interface() - slice, ok := rawSlice.([]string) + stringSlice, ok := rawSlice.([]string) if !ok { panic(fmt.Sprintf("string slice is of type %T", rawSlice)) } - value := make([]string, 0, len(slice)) - for _, entry := range slice { + value := make([]string, 0, len(stringSlice)) + for _, entry := range stringSlice { value = append(value, strings.Split(entry, ",")...) } val.FieldByName("Value").Set(reflect.ValueOf(value)) diff --git a/cli/deployment/config_test.go b/cli/deployment/config_test.go index bced504e01fab..d6d5b93d98043 100644 --- a/cli/deployment/config_test.go +++ b/cli/deployment/config_test.go @@ -232,6 +232,23 @@ func TestConfig(t *testing.T) { require.Equal(t, config.Prometheus.Enable.Value, true) require.Equal(t, config.Prometheus.Address.Value, config.Prometheus.Address.Default) }, + }, { + Name: "Experiments - no features", + Env: map[string]string{ + "CODER_EXPERIMENTS": "", + }, + Valid: func(config *codersdk.DeploymentConfig) { + require.Empty(t, config.Experiments.Value) + }, + }, { + Name: "Experiments - multiple features", + Env: map[string]string{ + "CODER_EXPERIMENTS": "foo,bar", + }, + Valid: func(config *codersdk.DeploymentConfig) { + expected := []string{"foo", "bar"} + require.ElementsMatch(t, expected, config.Experiments.Value) + }, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 0424c2c8dd9f1..797ca82d5d8be 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -61,10 +61,12 @@ Flags: Consumes $CODER_DERP_SERVER_STUN_ADDRESSES (default [stun.l.google.com:19302]) - --experimental Enable experimental features. - Experimental features are not ready for - production. - Consumes $CODER_EXPERIMENTAL + --experiments strings Enable one or more experiments. These are + not ready for production. Separate + multiple experiments with commas, or + enter '*' to opt-in to all available + experiments. + Consumes $CODER_EXPERIMENTS -h, --help help for server --http-address string HTTP bind address of the server. Unset to disable the HTTP endpoint. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 177c4230c94a1..3355084edd85a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b9a34ede0b0f3..50339d9e3e44b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/coderd.go b/coderd/coderd.go index c374b6ac60b92..5429eeee9cad9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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 +} diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index f03370376c7cb..1541fa2b119ae 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -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}, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 1d4a268b86137..cf28f4d2492d9 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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 diff --git a/coderd/experiments.go b/coderd/experiments.go new file mode 100644 index 0000000000000..651ae3155c369 --- /dev/null +++ b/coderd/experiments.go @@ -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) +} diff --git a/coderd/experiments_test.go b/coderd/experiments_test.go new file mode 100644 index 0000000000000..7a9da5370703a --- /dev/null +++ b/coderd/experiments_test.go @@ -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) + }) +} diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index 477a27b013e6c..1b7c9ddf072a3 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -41,11 +41,14 @@ type DeploymentConfig struct { SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"` Provisioner *ProvisionerConfig `json:"provisioner" typescript:",notnull"` RateLimit *RateLimitConfig `json:"rate_limit" typescript:",notnull"` - Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"` + Experiments *DeploymentConfigField[[]string] `json:"experiments" typescript:",notnull"` UpdateCheck *DeploymentConfigField[bool] `json:"update_check" typescript:",notnull"` MaxTokenLifetime *DeploymentConfigField[time.Duration] `json:"max_token_lifetime" typescript:",notnull"` Swagger *SwaggerConfig `json:"swagger" typescript:",notnull"` Logging *LoggingConfig `json:"logging" typescript:",notnull"` + + // DEPRECATED: Use Experiments instead. + Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"` } type DERP struct { diff --git a/codersdk/experiments.go b/codersdk/experiments.go new file mode 100644 index 0000000000000..0d6f1b78f5582 --- /dev/null +++ b/codersdk/experiments.go @@ -0,0 +1,53 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" +) + +type Experiment string + +const ( + // ExperimentVSCodeLocal enables a workspace button to launch VSCode + // and connect using the local VSCode extension. + ExperimentVSCodeLocal Experiment = "vscode_local" +) + +var ( + // ExperimentsAll should include all experiments that are safe for + // users to opt-in to via --experimental='*'. + // Experiments that are not ready for consumption by all users should + // not be included here and will be essentially hidden. + ExperimentsAll = Experiments{ + ExperimentVSCodeLocal, + } +) + +// Experiments is a list of experiments that are enabled for the deployment. +// Multiple experiments may be enabled at the same time. +// Experiments are not safe for production use, and are not guaranteed to +// be backwards compatible. They may be removed or renamed at any time. +type Experiments []Experiment + +func (e Experiments) Enabled(ex Experiment) bool { + for _, v := range e { + if v == ex { + return true + } + } + return false +} + +func (c *Client) Experiments(ctx context.Context) (Experiments, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var exp []Experiment + return exp, json.NewDecoder(res.Body).Decode(&exp) +} diff --git a/codersdk/features.go b/codersdk/features.go index 916df4db6110d..9ad04ea79afee 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -75,12 +75,14 @@ type Feature struct { } type Entitlements struct { - Features map[FeatureName]Feature `json:"features"` - Warnings []string `json:"warnings"` - Errors []string `json:"errors"` - HasLicense bool `json:"has_license"` - Experimental bool `json:"experimental"` - Trial bool `json:"trial"` + Features map[FeatureName]Feature `json:"features"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` + HasLicense bool `json:"has_license"` + Trial bool `json:"trial"` + + // DEPRECATED: use Experiments instead. + Experimental bool `json:"experimental"` } func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) { diff --git a/docs/api/general.md b/docs/api/general.md index 461226c00a998..447e6dde907ad 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -276,6 +276,17 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ "usage": "string", "value": true }, + "experiments": { + "default": ["string"], + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": ["string"] + }, "gitauth": { "default": [ { @@ -1008,6 +1019,43 @@ curl -X POST http://coder-server:8080/api/v2/csp/reports \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get experiments + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/experiments \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /experiments` + +### Example responses + +> 200 Response + +```json +["vscode_local"] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Experiment](schemas.md#codersdkexperiment) | + +