Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 69c9133

Browse files
committed
feat: Add workspace application support (#1773)
* feat: Add app support This adds apps as a property to a workspace agent. The resource is added to the Terraform provider here: coder/terraform-provider-coder#17 Apps will be opened in the dashboard or via the CLI with `coder open <name>`. If `command` is specified, a terminal will appear locally and in the web. If `target` is specified, the browser will open to an exposed instance of that target. * Compare fields in apps test * Update Terraform provider to use relative path * Add some basic structure for routing * chore: Remove interface from coderd and lift API surface Abstracting coderd into an interface added misdirection because the interface was never intended to be fulfilled outside of a single implementation. This lifts the abstraction, and attaches all handlers to a root struct named `*coderd.API`. * Add basic proxy logic * Add proxying based on path * Add app proxying for wildcards * Add wsconncache * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * fix: Race when writing to a closed pipe This is such an intermittent race it's difficult to track, but regardless this is an improvement to the code. * Add workspace route proxying endpoint - Makes the workspace conn cache concurrency-safe - Reduces unnecessary open checks in `peer.Channel` - Fixes the use of a temporary context when dialing a workspace agent * Add embed errors * chore: Refactor site to improve testing It was difficult to develop this package due to the embed build tag being mandatory on the tests. The logic to test doesn't require any embedded files. * Add test for error handler * Remove unused access url * Add RBAC tests * Fix dial agent syntax * Fix linting errors * Fix gen * Fix icon required * Adjust migration number * Fix proxy error status code * Fix empty db lookup
1 parent 9ad2e66 commit 69c9133

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1710
-268
lines changed

.vscode/settings.json

+11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"cSpell.words": [
3+
"apps",
4+
"awsidentity",
5+
"buildinfo",
36
"buildname",
47
"circbuf",
58
"cliflag",
@@ -16,6 +19,7 @@
1619
"Dsts",
1720
"fatih",
1821
"Formik",
22+
"gitsshkey",
1923
"goarch",
2024
"gographviz",
2125
"goleak",
@@ -30,6 +34,7 @@
3034
"incpatch",
3135
"isatty",
3236
"Jobf",
37+
"Keygen",
3338
"kirsle",
3439
"ldflags",
3540
"manifoldco",
@@ -54,6 +59,7 @@
5459
"retrier",
5560
"rpty",
5661
"sdkproto",
62+
"sdktrace",
5763
"Signup",
5864
"sourcemapped",
5965
"stretchr",
@@ -66,13 +72,18 @@
6672
"tfjson",
6773
"tfstate",
6874
"trimprefix",
75+
"turnconn",
6976
"typegen",
7077
"unconvert",
7178
"Untar",
7279
"VMID",
7380
"weblinks",
7481
"webrtc",
82+
"workspaceagent",
83+
"workspaceapp",
84+
"workspaceapps",
7585
"workspacebuilds",
86+
"wsconncache",
7687
"xerrors",
7788
"xstate",
7889
"yamux"

agent/conn.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func (c *Conn) DialContext(ctx context.Context, network string, addr string) (ne
102102
var res dialResponse
103103
err = dec.Decode(&res)
104104
if err != nil {
105-
return nil, xerrors.Errorf("failed to decode initial packet: %w", err)
105+
return nil, xerrors.Errorf("decode agent dial response: %w", err)
106106
}
107107
if res.Error != "" {
108108
_ = channel.Close()

coderd/coderd.go

+29-9
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/coder/coder/coderd/rbac"
2828
"github.com/coder/coder/coderd/tracing"
2929
"github.com/coder/coder/coderd/turnconn"
30+
"github.com/coder/coder/coderd/wsconncache"
3031
"github.com/coder/coder/codersdk"
3132
"github.com/coder/coder/site"
3233
)
@@ -44,14 +45,14 @@ type Options struct {
4445
// app. Specific routes may have their own limiters.
4546
APIRateLimit int
4647
AWSCertificates awsidentity.Certificates
48+
Authorizer rbac.Authorizer
4749
AzureCertificates x509.VerifyOptions
4850
GoogleTokenValidator *idtoken.Validator
4951
GithubOAuth2Config *GithubOAuth2Config
5052
ICEServers []webrtc.ICEServer
5153
SecureAuthCookie bool
5254
SSHKeygenAlgorithm gitsshkey.Algorithm
5355
TURNServer *turnconn.Server
54-
Authorizer rbac.Authorizer
5556
TracerProvider *sdktrace.TracerProvider
5657
}
5758

@@ -75,9 +76,11 @@ func New(options *Options) *API {
7576

7677
r := chi.NewRouter()
7778
api := &API{
78-
Options: options,
79-
Handler: r,
79+
Options: options,
80+
Handler: r,
81+
siteHandler: site.Handler(site.FS()),
8082
}
83+
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)
8184

8285
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, &httpmw.OAuth2Configs{
8386
Github: options.GithubOAuth2Config,
@@ -93,6 +96,20 @@ func New(options *Options) *API {
9396
tracing.HTTPMW(api.TracerProvider, "coderd.http"),
9497
)
9598

99+
apps := func(r chi.Router) {
100+
r.Use(
101+
httpmw.RateLimitPerMinute(options.APIRateLimit),
102+
apiKeyMiddleware,
103+
httpmw.ExtractUserParam(api.Database),
104+
)
105+
r.Get("/*", api.workspaceAppsProxyPath)
106+
}
107+
// %40 is the encoded character of the @ symbol. VS Code Web does
108+
// not handle character encoding properly, so it's safe to assume
109+
// other applications might not as well.
110+
r.Route("/%40{user}/{workspacename}/apps/{workspaceapp}", apps)
111+
r.Route("/@{user}/{workspacename}/apps/{workspaceapp}", apps)
112+
96113
r.Route("/api/v2", func(r chi.Router) {
97114
r.NotFound(func(rw http.ResponseWriter, r *http.Request) {
98115
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
@@ -327,24 +344,27 @@ func New(options *Options) *API {
327344
r.Get("/state", api.workspaceBuildState)
328345
})
329346
})
330-
r.NotFound(site.Handler(site.FS()).ServeHTTP)
331-
347+
r.NotFound(api.siteHandler.ServeHTTP)
332348
return api
333349
}
334350

335351
type API struct {
336352
*Options
337353

338-
Handler chi.Router
339-
websocketWaitMutex sync.Mutex
340-
websocketWaitGroup sync.WaitGroup
354+
Handler chi.Router
355+
siteHandler http.Handler
356+
websocketWaitMutex sync.Mutex
357+
websocketWaitGroup sync.WaitGroup
358+
workspaceAgentCache *wsconncache.Cache
341359
}
342360

343361
// Close waits for all WebSocket connections to drain before returning.
344-
func (api *API) Close() {
362+
func (api *API) Close() error {
345363
api.websocketWaitMutex.Lock()
346364
api.websocketWaitGroup.Wait()
347365
api.websocketWaitMutex.Unlock()
366+
367+
return api.workspaceAgentCache.Close()
348368
}
349369

350370
func debugLogRequest(log slog.Logger) func(http.Handler) http.Handler {

coderd/coderd_test.go

+14
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
7474
Agents: []*proto.Agent{{
7575
Id: "something",
7676
Auth: &proto.Agent_Token{},
77+
Apps: []*proto.App{{
78+
Name: "app",
79+
Url: "http://localhost:3000",
80+
}},
7781
}},
7882
}},
7983
},
@@ -128,6 +132,15 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
128132
"GET:/api/v2/users/authmethods": {NoAuthorize: true},
129133
"POST:/api/v2/csp/reports": {NoAuthorize: true},
130134

135+
"GET:/%40{user}/{workspacename}/apps/{application}/*": {
136+
AssertAction: rbac.ActionRead,
137+
AssertObject: workspaceRBACObj,
138+
},
139+
"GET:/@{user}/{workspacename}/apps/{application}/*": {
140+
AssertAction: rbac.ActionRead,
141+
AssertObject: workspaceRBACObj,
142+
},
143+
131144
// Has it's own auth
132145
"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},
133146

@@ -368,6 +381,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
368381
route = strings.ReplaceAll(route, "{template}", template.ID.String())
369382
route = strings.ReplaceAll(route, "{hash}", file.Hash)
370383
route = strings.ReplaceAll(route, "{workspaceresource}", workspaceResources[0].ID.String())
384+
route = strings.ReplaceAll(route, "{workspaceapp}", workspaceResources[0].Agents[0].Apps[0].Name)
371385
route = strings.ReplaceAll(route, "{templateversion}", version.ID.String())
372386
route = strings.ReplaceAll(route, "{templateversiondryrun}", templateVersionDryRun.ID.String())
373387
route = strings.ReplaceAll(route, "{templatename}", template.Name)

coderd/coderdtest/coderdtest.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, *coderd.API)
173173
cancelFunc()
174174
_ = turnServer.Close()
175175
srv.Close()
176-
coderAPI.Close()
176+
_ = coderAPI.Close()
177177
})
178178

179179
return codersdk.New(serverURL), coderAPI

coderd/database/databasefake/databasefake.go

+69
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func New() database.Store {
3535
templateVersions: make([]database.TemplateVersion, 0),
3636
templates: make([]database.Template, 0),
3737
workspaceBuilds: make([]database.WorkspaceBuild, 0),
38+
workspaceApps: make([]database.WorkspaceApp, 0),
3839
workspaces: make([]database.Workspace, 0),
3940
}
4041
}
@@ -63,6 +64,7 @@ type fakeQuerier struct {
6364
templateVersions []database.TemplateVersion
6465
templates []database.Template
6566
workspaceBuilds []database.WorkspaceBuild
67+
workspaceApps []database.WorkspaceApp
6668
workspaces []database.Workspace
6769
}
6870

@@ -388,6 +390,38 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa
388390
return database.Workspace{}, sql.ErrNoRows
389391
}
390392

393+
func (q *fakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID) ([]database.WorkspaceApp, error) {
394+
q.mutex.RLock()
395+
defer q.mutex.RUnlock()
396+
397+
apps := make([]database.WorkspaceApp, 0)
398+
for _, app := range q.workspaceApps {
399+
if app.AgentID == id {
400+
apps = append(apps, app)
401+
}
402+
}
403+
if len(apps) == 0 {
404+
return nil, sql.ErrNoRows
405+
}
406+
return apps, nil
407+
}
408+
409+
func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceApp, error) {
410+
q.mutex.RLock()
411+
defer q.mutex.RUnlock()
412+
413+
apps := make([]database.WorkspaceApp, 0)
414+
for _, app := range q.workspaceApps {
415+
for _, id := range ids {
416+
if app.AgentID.String() == id.String() {
417+
apps = append(apps, app)
418+
break
419+
}
420+
}
421+
}
422+
return apps, nil
423+
}
424+
391425
func (q *fakeQuerier) GetWorkspacesAutostart(_ context.Context) ([]database.Workspace, error) {
392426
q.mutex.RLock()
393427
defer q.mutex.RUnlock()
@@ -1031,6 +1065,22 @@ func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourc
10311065
return workspaceAgents, nil
10321066
}
10331067

1068+
func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndName(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndNameParams) (database.WorkspaceApp, error) {
1069+
q.mutex.RLock()
1070+
defer q.mutex.RUnlock()
1071+
1072+
for _, app := range q.workspaceApps {
1073+
if app.AgentID != arg.AgentID {
1074+
continue
1075+
}
1076+
if app.Name != arg.Name {
1077+
continue
1078+
}
1079+
return app, nil
1080+
}
1081+
return database.WorkspaceApp{}, sql.ErrNoRows
1082+
}
1083+
10341084
func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) (database.ProvisionerDaemon, error) {
10351085
q.mutex.RLock()
10361086
defer q.mutex.RUnlock()
@@ -1521,6 +1571,25 @@ func (q *fakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser
15211571
return workspaceBuild, nil
15221572
}
15231573

1574+
func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) {
1575+
q.mutex.Lock()
1576+
defer q.mutex.Unlock()
1577+
1578+
// nolint:gosimple
1579+
workspaceApp := database.WorkspaceApp{
1580+
ID: arg.ID,
1581+
AgentID: arg.AgentID,
1582+
CreatedAt: arg.CreatedAt,
1583+
Name: arg.Name,
1584+
Icon: arg.Icon,
1585+
Command: arg.Command,
1586+
Url: arg.Url,
1587+
RelativePath: arg.RelativePath,
1588+
}
1589+
q.workspaceApps = append(q.workspaceApps, workspaceApp)
1590+
return workspaceApp, nil
1591+
}
1592+
15241593
func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error {
15251594
q.mutex.Lock()
15261595
defer q.mutex.Unlock()

coderd/database/dump.sql

+20
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE workspace_apps;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE workspace_apps (
2+
id uuid NOT NULL,
3+
created_at timestamp with time zone NOT NULL,
4+
agent_id uuid NOT NULL REFERENCES workspace_agents (id) ON DELETE CASCADE,
5+
name varchar(64) NOT NULL,
6+
icon varchar(256) NOT NULL,
7+
command varchar(65534),
8+
url varchar(65534),
9+
relative_path boolean NOT NULL DEFAULT false,
10+
PRIMARY KEY (id),
11+
UNIQUE(agent_id, name)
12+
);

coderd/database/models.go

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/querier.go

+4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)