mirror of https://github.com/coder/coder.git
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:
parent
47c3d72294
commit
56b996532f
|
@ -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))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 + ")";
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package enums
|
||||
|
||||
type Enum string
|
||||
type Enums []Enum
|
||||
|
||||
const (
|
||||
EnumFoo Enum = "foo"
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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),
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -22,6 +22,7 @@ const emptyEntitlements = {
|
|||
features: withDefaultFeatures({}),
|
||||
has_license: false,
|
||||
experimental: false,
|
||||
experimental_features: [],
|
||||
trial: false,
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
)
|
Loading…
Reference in New Issue