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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 593 additions and 41 deletions

View File

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

View File

@ -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) {

View File

@ -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.

48
coderd/apidoc/docs.go generated
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": {

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": {

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
}

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},

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
coderd/experiments.go Normal file
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
coderd/experiments_test.go Normal file
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)
})
}

View File

@ -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 {

53
codersdk/experiments.go Normal file
View File

@ -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)
}

View File

@ -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) {

View File

@ -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) |
<h3 id="get-experiments-responseschema">Response Schema</h3>
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

View File

@ -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

View File

@ -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 {

View File

@ -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 + ")";

View File

@ -288,7 +288,7 @@ func (g *Generator) generateOne(m *Maps, obj types.Object) error {
// type <Name> 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.

View File

@ -1,6 +1,7 @@
package enums
type Enum string
type Enums []Enum
const (
EnumFoo Enum = "foo"

View File

@ -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"]

View File

@ -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

View File

@ -636,6 +636,18 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
}
}
export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
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<TypesGen.AuditLogResponse> => {

View File

@ -311,11 +311,12 @@ export interface DeploymentConfig {
readonly scim_api_key: DeploymentConfigField<string>
readonly provisioner: ProvisionerConfig
readonly rate_limit: RateLimitConfig
readonly experimental: DeploymentConfigField<boolean>
readonly experiments: DeploymentConfigField<string[]>
readonly update_check: DeploymentConfigField<boolean>
readonly max_token_lifetime: DeploymentConfigField<number>
readonly swagger: SwaggerConfig
readonly logging: LoggingConfig
readonly experimental: DeploymentConfigField<boolean>
}
// 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"

View File

@ -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,

View File

@ -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",

View File

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

View File

@ -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<typeof authMachine>
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
experimentsXService: ActorRefFrom<typeof experimentsMachine>
appearanceXService: ActorRefFrom<typeof appearanceMachine>
}
@ -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),
}}
>

View File

@ -22,6 +22,7 @@ const emptyEntitlements = {
features: withDefaultFeatures({}),
has_license: false,
experimental: false,
experimental_features: [],
trial: false,
}

View File

@ -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,
})),
},
},
)