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) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update check ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 91cb8f816960f..83cb1ba074c80 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1356,6 +1356,17 @@ CreateParameterRequest is a structure used to create a new parameter value for a "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": [ { @@ -2058,7 +2069,8 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `browser_only` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | | `cache_directory` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `derp` | [codersdk.DERP](#codersdkderp) | false | | | -| `experimental` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | +| `experimental` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | Experimental Use Experiments instead. | +| `experiments` | [codersdk.DeploymentConfigField-array_string](#codersdkdeploymentconfigfield-array_string) | false | | | | `gitauth` | [codersdk.DeploymentConfigField-array_codersdk_GitAuthConfig](#codersdkdeploymentconfigfield-array_codersdk_gitauthconfig) | false | | | | `http_address` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `in_memory_database` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | @@ -2332,15 +2344,29 @@ CreateParameterRequest is a structure used to create a new parameter value for a ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------------------------------------ | -------- | ------------ | ----------- | -| `errors` | array of string | false | | | -| `experimental` | boolean | false | | | -| `features` | object | false | | | -| » `[any property]` | [codersdk.Feature](#codersdkfeature) | false | | | -| `has_license` | boolean | false | | | -| `trial` | boolean | false | | | -| `warnings` | array of string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------------------------------ | -------- | ------------ | ------------------------------------- | +| `errors` | array of string | false | | | +| `experimental` | boolean | false | | Experimental use Experiments instead. | +| `features` | object | false | | | +| » `[any property]` | [codersdk.Feature](#codersdkfeature) | false | | | +| `has_license` | boolean | false | | | +| `trial` | boolean | false | | | +| `warnings` | array of string | false | | | + +## codersdk.Experiment + +```json +"vscode_local" +``` + +### Properties + +#### Enumerated Values + +| Value | +| -------------- | +| `vscode_local` | ## codersdk.Feature diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5ad7460b1dd26..f5d34df4f6e3e 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -250,7 +250,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if err != nil { return err } - entitlements.Experimental = api.DeploymentConfig.Experimental.Value + entitlements.Experimental = api.DeploymentConfig.Experimental.Value || len(api.AGPL.Experiments) != 0 featureChanged := func(featureName codersdk.FeatureName) (changed bool, enabled bool) { if api.entitlements.Features == nil { diff --git a/scripts/apidocgen/markdown-template/responses.def b/scripts/apidocgen/markdown-template/responses.def index 117320d16e976..02421e11c5ea5 100644 --- a/scripts/apidocgen/markdown-template/responses.def +++ b/scripts/apidocgen/markdown-template/responses.def @@ -13,6 +13,9 @@ if (!ref) { ref = content.schema.items["x-widdershins-oldRef"]; } + if (!ref) { + return content.schema.items.type; + } const aType = ref.replace("#/components/schemas/",""); const href = aType.replace(".","").toLowerCase(); return "[" + aType + "](schemas.md#" + href + ")"; diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index 1d068795c0eb4..765c8dd510d70 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -288,7 +288,7 @@ func (g *Generator) generateOne(m *Maps, obj types.Object) error { // type string // These are enums. Store to expand later. m.Enums[obj.Name()] = obj - case *types.Map: + case *types.Map, *types.Array, *types.Slice: // Declared maps that are not structs are still valid codersdk objects. // Handle them custom by calling 'typescriptType' directly instead of // iterating through each struct field. @@ -308,8 +308,6 @@ func (g *Generator) generateOne(m *Maps, obj types.Object) error { // Use similar output syntax to enums. str.WriteString(fmt.Sprintf("export type %s = %s\n", obj.Name(), ts.ValueType)) m.Structs[obj.Name()] = str.String() - case *types.Array, *types.Slice: - // TODO: @emyrk if you need this, follow the same design as "*types.Map" case. case *types.Interface: // Interfaces are used as generics. Non-generic interfaces are // not supported. diff --git a/scripts/apitypings/testdata/enums/enums.go b/scripts/apitypings/testdata/enums/enums.go index 7e6a5e71673f3..baf665c62dedd 100644 --- a/scripts/apitypings/testdata/enums/enums.go +++ b/scripts/apitypings/testdata/enums/enums.go @@ -1,6 +1,7 @@ package enums type Enum string +type Enums []Enum const ( EnumFoo Enum = "foo" diff --git a/scripts/apitypings/testdata/enums/enums.ts b/scripts/apitypings/testdata/enums/enums.ts index 21b6f1934e499..bf9863da23dfe 100644 --- a/scripts/apitypings/testdata/enums/enums.ts +++ b/scripts/apitypings/testdata/enums/enums.ts @@ -1,5 +1,8 @@ // Code generated by 'make site/src/api/typesGenerated.ts'. DO NOT EDIT. +// From codersdk/enums.go +export type Enums = Enum[] + // From codersdk/enums.go export type Enum = "bar" | "baz" | "foo" | "qux" export const Enums: Enum[] = ["bar", "baz", "foo", "qux"] diff --git a/scripts/develop.sh b/scripts/develop.sh index 3ee90f5a9db93..9620af0a76dc6 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -121,7 +121,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script diff --git a/site/src/api/api.ts b/site/src/api/api.ts index fb0cac686b734..f1798e2886f33 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -636,6 +636,18 @@ export const getEntitlements = async (): Promise => { } } +export const getExperiments = async (): Promise => { + try { + const response = await axios.get("/api/v2/experiments") + return response.data + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return [] + } + throw error + } +} + export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7d098c55b9138..6793d66ca0fbf 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -311,11 +311,12 @@ export interface DeploymentConfig { readonly scim_api_key: DeploymentConfigField readonly provisioner: ProvisionerConfig readonly rate_limit: RateLimitConfig - readonly experimental: DeploymentConfigField + readonly experiments: DeploymentConfigField readonly update_check: DeploymentConfigField readonly max_token_lifetime: DeploymentConfigField readonly swagger: SwaggerConfig readonly logging: LoggingConfig + readonly experimental: DeploymentConfigField } // From codersdk/deploymentconfig.go @@ -337,10 +338,13 @@ export interface Entitlements { readonly warnings: string[] readonly errors: string[] readonly has_license: boolean - readonly experimental: boolean readonly trial: boolean + readonly experimental: boolean } +// From codersdk/experiments.go +export type Experiments = Experiment[] + // From codersdk/features.go export interface Feature { readonly entitlement: Entitlement @@ -1079,6 +1083,10 @@ export const Entitlements: Entitlement[] = [ "not_entitled", ] +// From codersdk/experiments.go +export type Experiment = "vscode_local" +export const Experiments: Experiment[] = ["vscode_local"] + // From codersdk/features.go export type FeatureName = | "appearance" diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 3c8f6324ac35b..0041901b01897 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -41,9 +41,9 @@ export const WorkspaceReadyPage = ({ workspaceState.children["scheduleBannerMachine"], ) const xServices = useContext(XServiceContext) - const experimental = useSelector( - xServices.entitlementsXService, - (state) => state.context.entitlements.experimental, + const experiments = useSelector( + xServices.experimentsXService, + (state) => state.context.experiments || [], ) const featureVisibility = useSelector( xServices.entitlementsXService, @@ -124,7 +124,8 @@ export const WorkspaceReadyPage = ({ canUpdateWorkspace={canUpdateWorkspace} hideSSHButton={featureVisibility["browser_only"]} hideVSCodeDesktopButton={ - !experimental || featureVisibility["browser_only"] + !experiments.includes("vscode_local") || + featureVisibility["browser_only"] } workspaceErrors={{ [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dc57d1857327d..1b79af3fcdd0a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -982,6 +982,8 @@ export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { }), } +export const MockExperiments: TypesGen.Experiment[] = ["vscode_local"] + export const MockAuditLog: TypesGen.AuditLog = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", request_id: "53bded77-7b9d-4e82-8771-991a34d759f9", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 92d3d8e8e6d69..98a02293218bb 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -15,6 +15,11 @@ export const handlers = [ return res(ctx.status(200), ctx.json(M.MockBuildInfo)) }), + // experiments + rest.get("/api/v2/experiments", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockExperiments)) + }), + // update check rest.get("/api/v2/updatecheck", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockUpdateCheck)) diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index d77c94defb09e..e7e958e50409c 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -4,12 +4,14 @@ import { ActorRefFrom } from "xstate" import { authMachine } from "./auth/authXService" import { buildInfoMachine } from "./buildInfo/buildInfoXService" import { entitlementsMachine } from "./entitlements/entitlementsXService" +import { experimentsMachine } from "./experiments/experimentsMachine" import { appearanceMachine } from "./appearance/appearanceXService" interface XServiceContextType { authXService: ActorRefFrom buildInfoXService: ActorRefFrom entitlementsXService: ActorRefFrom + experimentsXService: ActorRefFrom appearanceXService: ActorRefFrom } @@ -30,6 +32,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => { authXService: useInterpret(authMachine), buildInfoXService: useInterpret(buildInfoMachine), entitlementsXService: useInterpret(entitlementsMachine), + experimentsXService: useInterpret(experimentsMachine), appearanceXService: useInterpret(appearanceMachine), }} > diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts index 52bceac1b2967..96b175897c70b 100644 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -22,6 +22,7 @@ const emptyEntitlements = { features: withDefaultFeatures({}), has_license: false, experimental: false, + experimental_features: [], trial: false, } diff --git a/site/src/xServices/experiments/experimentsMachine.ts b/site/src/xServices/experiments/experimentsMachine.ts new file mode 100644 index 0000000000000..25acab364bf0b --- /dev/null +++ b/site/src/xServices/experiments/experimentsMachine.ts @@ -0,0 +1,75 @@ +import { getExperiments } from "api/api" +import { Experiment } from "api/typesGenerated" +import { createMachine, assign } from "xstate" + +export interface ExperimentsContext { + experiments?: Experiment[] + getExperimentsError?: Error | unknown +} + +export const experimentsMachine = createMachine( + { + id: "experimentsState", + predictableActionArguments: true, + tsTypes: {} as import("./experimentsMachine.typegen").Typegen0, + schema: { + context: {} as ExperimentsContext, + services: {} as { + getExperiments: { + data: Experiment[] + } + }, + }, + context: { + experiments: undefined, + }, + initial: "gettingExperiments", + states: { + gettingExperiments: { + invoke: { + src: "getExperiments", + id: "getExperiments", + onDone: [ + { + actions: ["assignExperiments", "clearGetExperimentsError"], + target: "#experimentsState.success", + }, + ], + onError: [ + { + actions: ["assignGetExperimentsError", "clearExperiments"], + target: "#experimentsState.failure", + }, + ], + }, + }, + success: { + type: "final", + }, + failure: { + type: "final", + }, + }, + }, + { + services: { + getExperiments: getExperiments, + }, + actions: { + assignExperiments: assign({ + experiments: (_, event) => event.data, + }), + clearExperiments: assign((context: ExperimentsContext) => ({ + ...context, + experiments: undefined, + })), + assignGetExperimentsError: assign({ + getExperimentsError: (_, event) => event.data, + }), + clearGetExperimentsError: assign((context: ExperimentsContext) => ({ + ...context, + getExperimentsError: undefined, + })), + }, + }, +)