mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: Add browser-only connections to Enterprise (#4135)
* feat: Add browser-only connections to Enterprise Fixes #4131. * Fix formatting
This commit is contained in:
+3
-2
@@ -489,8 +489,9 @@ func New(options *Options) *API {
|
||||
|
||||
type API struct {
|
||||
*Options
|
||||
Auditor atomic.Pointer[audit.Auditor]
|
||||
HTTPAuth *HTTPAuthorizer
|
||||
Auditor atomic.Pointer[audit.Auditor]
|
||||
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
|
||||
HTTPAuth *HTTPAuthorizer
|
||||
|
||||
// APIHandler serves "/api/v2"
|
||||
APIHandler chi.Router
|
||||
|
||||
@@ -403,6 +403,11 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
// This is used by Enterprise code to control the functionality of this route.
|
||||
override := api.WorkspaceClientCoordinateOverride.Load()
|
||||
if override != nil && (*override)(rw) {
|
||||
return
|
||||
}
|
||||
|
||||
api.websocketWaitMutex.Lock()
|
||||
api.websocketWaitGroup.Add(1)
|
||||
|
||||
@@ -15,12 +15,13 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
FeatureUserLimit = "user_limit"
|
||||
FeatureAuditLog = "audit_log"
|
||||
FeatureSCIM = "scim"
|
||||
FeatureUserLimit = "user_limit"
|
||||
FeatureAuditLog = "audit_log"
|
||||
FeatureBrowserOnly = "browser_only"
|
||||
FeatureSCIM = "scim"
|
||||
)
|
||||
|
||||
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureSCIM}
|
||||
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM}
|
||||
|
||||
type Feature struct {
|
||||
Entitlement Entitlement `json:"entitlement"`
|
||||
|
||||
@@ -270,12 +270,14 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
closed := make(chan struct{})
|
||||
first := make(chan error)
|
||||
go func() {
|
||||
defer close(closed)
|
||||
isFirst := true
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
||||
logger.Debug(ctx, "connecting")
|
||||
// nolint:bodyclose
|
||||
ws, _, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
|
||||
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
// Need to disable compression to avoid a data-race.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
@@ -283,10 +285,22 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if isFirst {
|
||||
if res.StatusCode == http.StatusConflict {
|
||||
first <- readBodyAsError(res)
|
||||
return
|
||||
}
|
||||
isFirst = false
|
||||
close(first)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "failed to dial", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
if isFirst {
|
||||
isFirst = false
|
||||
close(first)
|
||||
}
|
||||
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {
|
||||
return conn.UpdateNodes(node)
|
||||
})
|
||||
@@ -305,13 +319,19 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
|
||||
_ = ws.Close(websocket.StatusAbnormalClosure, "")
|
||||
}
|
||||
}()
|
||||
err = <-first
|
||||
if err != nil {
|
||||
cancelFunc()
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &agent.Conn{
|
||||
Conn: conn,
|
||||
CloseFunc: func() {
|
||||
cancelFunc()
|
||||
<-closed
|
||||
},
|
||||
}, nil
|
||||
}, err
|
||||
}
|
||||
|
||||
// WorkspaceAgent returns an agent by ID.
|
||||
|
||||
@@ -6,6 +6,7 @@ Contact sales@coder.com to obtain a license.
|
||||
These features are:
|
||||
|
||||
* Audit Logging
|
||||
* Browser Only Connections
|
||||
|
||||
## Adding your license key
|
||||
|
||||
|
||||
@@ -57,12 +57,14 @@ func TestFeaturesList(t *testing.T) {
|
||||
var entitlements codersdk.Entitlements
|
||||
err := json.Unmarshal(buf.Bytes(), &entitlements)
|
||||
require.NoError(t, err, "unmarshal JSON output")
|
||||
assert.Len(t, entitlements.Features, 2)
|
||||
assert.Len(t, entitlements.Features, 3)
|
||||
assert.Empty(t, entitlements.Warnings)
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement)
|
||||
assert.False(t, entitlements.HasLicense)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/enterprise/coderd"
|
||||
|
||||
agpl "github.com/coder/coder/cli"
|
||||
@@ -15,11 +16,13 @@ import (
|
||||
func server() *cobra.Command {
|
||||
var (
|
||||
auditLogging bool
|
||||
browserOnly bool
|
||||
scimAuthHeader string
|
||||
)
|
||||
cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) {
|
||||
api, err := coderd.New(ctx, &coderd.Options{
|
||||
AuditLogging: auditLogging,
|
||||
BrowserOnly: browserOnly,
|
||||
SCIMAPIKey: []byte(scimAuthHeader),
|
||||
Options: options,
|
||||
})
|
||||
@@ -28,9 +31,14 @@ func server() *cobra.Command {
|
||||
}
|
||||
return api.AGPL, nil
|
||||
})
|
||||
enterpriseOnly := cliui.Styles.Keyword.Render("This is an Enterprise feature. Contact sales@coder.com for licensing")
|
||||
|
||||
cliflag.BoolVarP(cmd.Flags(), &auditLogging, "audit-logging", "", "CODER_AUDIT_LOGGING", true,
|
||||
"Specifies whether audit logging is enabled.")
|
||||
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.")
|
||||
"Specifies whether audit logging is enabled. "+enterpriseOnly)
|
||||
cliflag.BoolVarP(cmd.Flags(), &browserOnly, "browser-only", "", "CODER_BROWSER_ONLY", false,
|
||||
"Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly)
|
||||
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "",
|
||||
"Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||
OIDC: options.OIDCConfig,
|
||||
}
|
||||
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false)
|
||||
|
||||
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
||||
r.Get("/entitlements", api.serveEntitlements)
|
||||
r.Route("/licenses", func(r chi.Router) {
|
||||
@@ -88,7 +87,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||
type Options struct {
|
||||
*coderd.Options
|
||||
|
||||
AuditLogging bool
|
||||
AuditLogging bool
|
||||
// Whether to block non-browser connections.
|
||||
BrowserOnly bool
|
||||
SCIMAPIKey []byte
|
||||
EntitlementsUpdateInterval time.Duration
|
||||
Keys map[string]ed25519.PublicKey
|
||||
@@ -107,6 +108,7 @@ type entitlements struct {
|
||||
hasLicense bool
|
||||
activeUsers codersdk.Feature
|
||||
auditLogs codersdk.Entitlement
|
||||
browserOnly codersdk.Entitlement
|
||||
scim codersdk.Entitlement
|
||||
}
|
||||
|
||||
@@ -131,8 +133,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
Enabled: false,
|
||||
Entitlement: codersdk.EntitlementNotEntitled,
|
||||
},
|
||||
auditLogs: codersdk.EntitlementNotEntitled,
|
||||
scim: codersdk.EntitlementNotEntitled,
|
||||
auditLogs: codersdk.EntitlementNotEntitled,
|
||||
scim: codersdk.EntitlementNotEntitled,
|
||||
browserOnly: codersdk.EntitlementNotEntitled,
|
||||
}
|
||||
|
||||
// Here we loop through licenses to detect enabled features.
|
||||
@@ -165,6 +168,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
if claims.Features.AuditLog > 0 {
|
||||
entitlements.auditLogs = entitlement
|
||||
}
|
||||
if claims.Features.BrowserOnly > 0 {
|
||||
entitlements.browserOnly = entitlement
|
||||
}
|
||||
if claims.Features.SCIM > 0 {
|
||||
entitlements.scim = entitlement
|
||||
}
|
||||
@@ -174,7 +180,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
auditor := agplaudit.NewNop()
|
||||
// A flag could be added to the options that would allow disabling
|
||||
// enhanced audit logging here!
|
||||
if entitlements.auditLogs == codersdk.EntitlementEntitled && api.AuditLogging {
|
||||
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
|
||||
auditor = audit.NewAuditor(
|
||||
audit.DefaultFilter,
|
||||
backends.NewPostgres(api.Database, true),
|
||||
@@ -184,6 +190,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
api.AGPL.Auditor.Store(&auditor)
|
||||
}
|
||||
|
||||
if entitlements.browserOnly != api.entitlements.browserOnly {
|
||||
var handler func(rw http.ResponseWriter) bool
|
||||
if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly {
|
||||
handler = api.shouldBlockNonBrowserConnections
|
||||
}
|
||||
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
|
||||
}
|
||||
|
||||
api.entitlements = entitlements
|
||||
|
||||
return nil
|
||||
@@ -230,6 +244,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
|
||||
"Audit logging is enabled but your license for this feature is expired.")
|
||||
}
|
||||
|
||||
resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{
|
||||
Entitlement: entitlements.browserOnly,
|
||||
Enabled: api.BrowserOnly,
|
||||
}
|
||||
if entitlements.browserOnly == codersdk.EntitlementGracePeriod && api.BrowserOnly {
|
||||
resp.Warnings = append(resp.Warnings,
|
||||
"Browser only connections are enabled but your license for this feature is expired.")
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -84,15 +84,18 @@ func TestEntitlements(t *testing.T) {
|
||||
})
|
||||
t.Run("Warnings", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdenttest.New(t, nil)
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
BrowserOnly: true,
|
||||
})
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
for i := 0; i < 4; i++ {
|
||||
coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
||||
}
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
UserLimit: 4,
|
||||
AuditLog: true,
|
||||
GraceAt: time.Now().Add(-time.Second),
|
||||
UserLimit: 4,
|
||||
AuditLog: true,
|
||||
BrowserOnly: true,
|
||||
GraceAt: time.Now().Add(-time.Second),
|
||||
})
|
||||
res, err := client.Entitlements(context.Background())
|
||||
require.NoError(t, err)
|
||||
@@ -107,11 +110,18 @@ func TestEntitlements(t *testing.T) {
|
||||
assert.True(t, al.Enabled)
|
||||
assert.Nil(t, al.Limit)
|
||||
assert.Nil(t, al.Actual)
|
||||
assert.Len(t, res.Warnings, 2)
|
||||
bo := res.Features[codersdk.FeatureBrowserOnly]
|
||||
assert.Equal(t, codersdk.EntitlementGracePeriod, bo.Entitlement)
|
||||
assert.True(t, bo.Enabled)
|
||||
assert.Nil(t, bo.Limit)
|
||||
assert.Nil(t, bo.Actual)
|
||||
assert.Len(t, res.Warnings, 3)
|
||||
assert.Contains(t, res.Warnings,
|
||||
"Your deployment has 5 active users but is only licensed for 4.")
|
||||
assert.Contains(t, res.Warnings,
|
||||
"Audit logging is enabled but your license for this feature is expired.")
|
||||
assert.Contains(t, res.Warnings,
|
||||
"Browser only connections are enabled but your license for this feature is expired.")
|
||||
})
|
||||
t.Run("Pubsub", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -36,6 +36,7 @@ func init() {
|
||||
|
||||
type Options struct {
|
||||
*coderdtest.Options
|
||||
BrowserOnly bool
|
||||
EntitlementsUpdateInterval time.Duration
|
||||
SCIMAPIKey []byte
|
||||
}
|
||||
@@ -56,6 +57,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options)
|
||||
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
|
||||
AuditLogging: true,
|
||||
BrowserOnly: options.BrowserOnly,
|
||||
SCIMAPIKey: options.SCIMAPIKey,
|
||||
Options: oop,
|
||||
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
|
||||
@@ -84,6 +86,7 @@ type LicenseOptions struct {
|
||||
ExpiresAt time.Time
|
||||
UserLimit int64
|
||||
AuditLog bool
|
||||
BrowserOnly bool
|
||||
SCIM bool
|
||||
}
|
||||
|
||||
@@ -108,6 +111,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
if options.AuditLog {
|
||||
auditLog = 1
|
||||
}
|
||||
browserOnly := int64(0)
|
||||
if options.BrowserOnly {
|
||||
browserOnly = 1
|
||||
}
|
||||
scim := int64(0)
|
||||
if options.SCIM {
|
||||
scim = 1
|
||||
@@ -125,9 +132,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
AccountID: options.AccountID,
|
||||
Version: coderd.CurrentVersion,
|
||||
Features: coderd.Features{
|
||||
UserLimit: options.UserLimit,
|
||||
AuditLog: auditLog,
|
||||
SCIM: scim,
|
||||
UserLimit: options.UserLimit,
|
||||
AuditLog: auditLog,
|
||||
BrowserOnly: browserOnly,
|
||||
SCIM: scim,
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
|
||||
|
||||
@@ -45,9 +45,10 @@ var key20220812 []byte
|
||||
var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)}
|
||||
|
||||
type Features struct {
|
||||
UserLimit int64 `json:"user_limit"`
|
||||
AuditLog int64 `json:"audit_log"`
|
||||
SCIM int64 `json:"scim"`
|
||||
UserLimit int64 `json:"user_limit"`
|
||||
AuditLog int64 `json:"audit_log"`
|
||||
BrowserOnly int64 `json:"browser_only"`
|
||||
SCIM int64 `json:"scim"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
|
||||
@@ -78,16 +78,18 @@ func TestGetLicense(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
AccountID: "testing",
|
||||
AuditLog: true,
|
||||
SCIM: true,
|
||||
AccountID: "testing",
|
||||
AuditLog: true,
|
||||
SCIM: true,
|
||||
BrowserOnly: true,
|
||||
})
|
||||
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
AccountID: "testing2",
|
||||
AuditLog: true,
|
||||
SCIM: true,
|
||||
UserLimit: 200,
|
||||
AccountID: "testing2",
|
||||
AuditLog: true,
|
||||
SCIM: true,
|
||||
BrowserOnly: true,
|
||||
UserLimit: 200,
|
||||
})
|
||||
|
||||
licenses, err := client.Licenses(ctx)
|
||||
@@ -96,16 +98,18 @@ func TestGetLicense(t *testing.T) {
|
||||
assert.Equal(t, int32(1), licenses[0].ID)
|
||||
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
codersdk.FeatureUserLimit: json.Number("0"),
|
||||
codersdk.FeatureAuditLog: json.Number("1"),
|
||||
codersdk.FeatureSCIM: json.Number("1"),
|
||||
codersdk.FeatureUserLimit: json.Number("0"),
|
||||
codersdk.FeatureAuditLog: json.Number("1"),
|
||||
codersdk.FeatureSCIM: json.Number("1"),
|
||||
codersdk.FeatureBrowserOnly: json.Number("1"),
|
||||
}, licenses[0].Claims["features"])
|
||||
assert.Equal(t, int32(2), licenses[1].ID)
|
||||
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
codersdk.FeatureUserLimit: json.Number("200"),
|
||||
codersdk.FeatureAuditLog: json.Number("1"),
|
||||
codersdk.FeatureSCIM: json.Number("1"),
|
||||
codersdk.FeatureUserLimit: json.Number("200"),
|
||||
codersdk.FeatureAuditLog: json.Number("1"),
|
||||
codersdk.FeatureSCIM: json.Number("1"),
|
||||
codersdk.FeatureBrowserOnly: json.Number("1"),
|
||||
}, licenses[1].Claims["features"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (api *API) shouldBlockNonBrowserConnections(rw http.ResponseWriter) bool {
|
||||
api.entitlementsMu.Lock()
|
||||
browserOnly := api.entitlements.browserOnly
|
||||
api.entitlementsMu.Unlock()
|
||||
if api.BrowserOnly && browserOnly != codersdk.EntitlementNotEntitled {
|
||||
httpapi.Write(context.Background(), rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Non-browser connections are disabled for your deployment.",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestBlockNonBrowser(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Enabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
BrowserOnly: true,
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
BrowserOnly: true,
|
||||
})
|
||||
id := setupWorkspaceAgent(t, client, user)
|
||||
_, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
|
||||
})
|
||||
t.Run("Disabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
BrowserOnly: false,
|
||||
})
|
||||
id := setupWorkspaceAgent(t, client, user)
|
||||
conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id)
|
||||
require.NoError(t, err)
|
||||
_ = conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) uuid.UUID {
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
return resources[0].Agents[0].ID
|
||||
}
|
||||
@@ -19,4 +19,5 @@ export type Message = { message: string }
|
||||
export enum FeatureNames {
|
||||
AuditLog = "audit_log",
|
||||
UserLimit = "user_limit",
|
||||
BrowserOnly = "browser_only",
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface ResourcesProps {
|
||||
workspace: Workspace
|
||||
canUpdateWorkspace: boolean
|
||||
buildInfo?: BuildInfoResponse | undefined
|
||||
hideSSHButton?: boolean
|
||||
}
|
||||
|
||||
export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
@@ -49,6 +50,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
workspace,
|
||||
canUpdateWorkspace,
|
||||
buildInfo,
|
||||
hideSSHButton,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const theme: Theme = useTheme()
|
||||
@@ -149,7 +151,12 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
<div className={styles.accessLinks}>
|
||||
{canUpdateWorkspace && agent.status === "connected" && (
|
||||
<>
|
||||
<SSHButton workspaceName={workspace.name} agentName={agent.name} />
|
||||
{!hideSSHButton && (
|
||||
<SSHButton
|
||||
workspaceName={workspace.name}
|
||||
agentName={agent.name}
|
||||
/>
|
||||
)}
|
||||
<TerminalLink
|
||||
workspaceName={workspace.name}
|
||||
agentName={agent.name}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface WorkspaceProps {
|
||||
resources?: TypesGen.WorkspaceResource[]
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
canUpdateWorkspace: boolean
|
||||
hideSSHButton?: boolean
|
||||
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
}
|
||||
@@ -63,6 +64,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
builds,
|
||||
canUpdateWorkspace,
|
||||
workspaceErrors,
|
||||
hideSSHButton,
|
||||
buildInfo,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
@@ -137,6 +139,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
workspace={workspace}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
buildInfo={buildInfo}
|
||||
hideSSHButton={hideSSHButton}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { useActor, useMachine, useSelector } from "@xstate/react"
|
||||
import { FeatureNames } from "api/types"
|
||||
import dayjs from "dayjs"
|
||||
import minMax from "dayjs/plugin/minMax"
|
||||
import { FC, useContext, useEffect } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
|
||||
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
|
||||
@@ -30,6 +32,7 @@ export const WorkspacePage: FC = () => {
|
||||
|
||||
const xServices = useContext(XServiceContext)
|
||||
const me = useSelector(xServices.authXService, selectUser)
|
||||
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
|
||||
|
||||
const [workspaceState, workspaceSend] = useMachine(workspaceMachine, {
|
||||
context: {
|
||||
@@ -130,6 +133,7 @@ export const WorkspacePage: FC = () => {
|
||||
resources={workspace.latest_build.resources}
|
||||
builds={builds}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]}
|
||||
workspaceErrors={{
|
||||
[WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning,
|
||||
[WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
|
||||
|
||||
@@ -768,6 +768,10 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
browser_only: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user