From 3ab3a62bef13b7bd8e6cb67e7505d64cd96c787f Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 13 Feb 2024 09:31:20 -0500 Subject: [PATCH] feat: add port-sharing backend (#11939) --- coderd/apidoc/docs.go | 195 +++++++++++++++++- coderd/apidoc/swagger.json | 175 +++++++++++++++- coderd/coderd.go | 11 + coderd/database/dbauthz/dbauthz.go | 56 +++++ coderd/database/dbauthz/dbauthz_test.go | 49 ++++- coderd/database/dbgen/dbgen.go | 12 ++ coderd/database/dbmem/dbmem.go | 83 ++++++++ coderd/database/dbmetrics/dbmetrics.go | 28 +++ coderd/database/dbmock/dbmock.go | 59 ++++++ coderd/database/dump.sql | 17 +- coderd/database/foreign_key_constraint.go | 1 + ...0191_workspace_agent_port_sharing.down.sql | 20 ++ ...000191_workspace_agent_port_sharing.up.sql | 27 +++ .../000191_ workspace_agent_port_share.up.sql | 4 + coderd/database/modelqueries.go | 1 + coderd/database/models.go | 15 +- coderd/database/querier.go | 4 + coderd/database/queries.sql.go | 141 +++++++++++-- coderd/database/queries/templates.sql | 8 +- .../queries/workspaceagentportshare.sql | 13 ++ coderd/database/unique_constraint.go | 1 + coderd/httpmw/experiments.go | 23 +++ coderd/metricscache/metricscache_test.go | 5 +- coderd/portsharing/portsharing.go | 25 +++ coderd/templates.go | 16 +- coderd/templates_test.go | 30 +++ coderd/workspaceagentportshare.go | 173 ++++++++++++++++ coderd/workspaceagentportshare_test.go | 168 +++++++++++++++ coderd/workspaceapps/apptest/apptest.go | 73 +++++++ coderd/workspaceapps/request.go | 31 +++ coderd/workspaceapps_test.go | 1 + codersdk/deployment.go | 9 +- codersdk/templates.go | 6 +- codersdk/workspaceagentportshare.go | 89 ++++++++ docs/admin/audit-logs.md | 28 +-- docs/api/portsharing.md | 90 ++++++++ docs/api/schemas.md | 100 ++++++++- docs/api/templates.md | 15 +- docs/manifest.json | 4 + enterprise/audit/table.go | 1 + enterprise/coderd/coderd.go | 11 + enterprise/coderd/portsharing/portsharing.go | 40 ++++ enterprise/coderd/templates_test.go | 37 ++++ enterprise/coderd/workspaceportshare_test.go | 63 ++++++ site/e2e/helpers.ts | 4 + site/src/api/typesGenerated.ts | 42 +++- .../TemplateSettingsPage.test.tsx | 1 + site/src/testHelpers/entities.ts | 1 + 48 files changed, 1947 insertions(+), 59 deletions(-) create mode 100644 coderd/database/migrations/000191_workspace_agent_port_sharing.down.sql create mode 100644 coderd/database/migrations/000191_workspace_agent_port_sharing.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000191_ workspace_agent_port_share.up.sql create mode 100644 coderd/database/queries/workspaceagentportshare.sql create mode 100644 coderd/httpmw/experiments.go create mode 100644 coderd/portsharing/portsharing.go create mode 100644 coderd/workspaceagentportshare.go create mode 100644 coderd/workspaceagentportshare_test.go create mode 100644 codersdk/workspaceagentportshare.go create mode 100644 docs/api/portsharing.md create mode 100644 enterprise/coderd/portsharing/portsharing.go create mode 100644 enterprise/coderd/workspaceportshare_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9fa88251a4..a4e792067c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7188,6 +7188,125 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/port-share": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "PortSharing" + ], + "summary": "Get workspace agent port shares", + "operationId": "get-workspace-agent-port-shares", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShares" + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PortSharing" + ], + "summary": "Upsert workspace agent port share", + "operationId": "upsert-workspace-agent-port-share", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Upsert port sharing level request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpsertWorkspaceAgentPortShareRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShare" + } + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "PortSharing" + ], + "summary": "Get workspace agent port shares", + "operationId": "get-workspace-agent-port-shares", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Delete port sharing level request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.DeleteWorkspaceAgentPortShareRequest" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/workspaces/{workspace}/resolve-autostart": { "get": { "security": [ @@ -9028,6 +9147,17 @@ const docTemplate = `{ } } }, + "codersdk.DeleteWorkspaceAgentPortShareRequest": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "port": { + "type": "integer" + } + } + }, "codersdk.DeploymentConfig": { "type": "object", "properties": { @@ -9337,13 +9467,15 @@ const docTemplate = `{ "codersdk.Experiment": { "type": "string", "enum": [ - "example" + "example", + "shared-ports" ], "x-enum-comments": { "ExperimentExample": "This isn't used for anything." }, "x-enum-varnames": [ - "ExperimentExample" + "ExperimentExample", + "ExperimentSharedPorts" ] }, "codersdk.ExternalAuth": { @@ -11022,6 +11154,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "max_port_share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once autostop_requirement is matured", "type": "integer" @@ -11839,6 +11974,20 @@ const docTemplate = `{ } } }, + "codersdk.UpsertWorkspaceAgentPortShareRequest": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + } + } + }, "codersdk.User": { "type": "object", "required": [ @@ -12533,6 +12682,48 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentPortShare": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.WorkspaceAgentPortShareLevel": { + "type": "string", + "enum": [ + "owner", + "authenticated", + "public" + ], + "x-enum-varnames": [ + "WorkspaceAgentPortShareLevelOwner", + "WorkspaceAgentPortShareLevelAuthenticated", + "WorkspaceAgentPortShareLevelPublic" + ] + }, + "codersdk.WorkspaceAgentPortShares": { + "type": "object", + "properties": { + "shares": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShare" + } + } + } + }, "codersdk.WorkspaceAgentScript": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f7f9b8585..1fd4acd85d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6350,6 +6350,111 @@ } } }, + "/workspaces/{workspace}/port-share": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["PortSharing"], + "summary": "Get workspace agent port shares", + "operationId": "get-workspace-agent-port-shares", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShares" + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["PortSharing"], + "summary": "Upsert workspace agent port share", + "operationId": "upsert-workspace-agent-port-share", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Upsert port sharing level request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpsertWorkspaceAgentPortShareRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShare" + } + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["PortSharing"], + "summary": "Get workspace agent port shares", + "operationId": "get-workspace-agent-port-shares", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Delete port sharing level request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.DeleteWorkspaceAgentPortShareRequest" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/workspaces/{workspace}/resolve-autostart": { "get": { "security": [ @@ -8064,6 +8169,17 @@ } } }, + "codersdk.DeleteWorkspaceAgentPortShareRequest": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "port": { + "type": "integer" + } + } + }, "codersdk.DeploymentConfig": { "type": "object", "properties": { @@ -8368,11 +8484,11 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["example"], + "enum": ["example", "shared-ports"], "x-enum-comments": { "ExperimentExample": "This isn't used for anything." }, - "x-enum-varnames": ["ExperimentExample"] + "x-enum-varnames": ["ExperimentExample", "ExperimentSharedPorts"] }, "codersdk.ExternalAuth": { "type": "object", @@ -9964,6 +10080,9 @@ "type": "string", "format": "uuid" }, + "max_port_share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once autostop_requirement is matured", "type": "integer" @@ -10730,6 +10849,20 @@ } } }, + "codersdk.UpsertWorkspaceAgentPortShareRequest": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + } + } + }, "codersdk.User": { "type": "object", "required": ["created_at", "email", "id", "username"], @@ -11403,6 +11536,44 @@ } } }, + "codersdk.WorkspaceAgentPortShare": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.WorkspaceAgentPortShareLevel": { + "type": "string", + "enum": ["owner", "authenticated", "public"], + "x-enum-varnames": [ + "WorkspaceAgentPortShareLevelOwner", + "WorkspaceAgentPortShareLevelAuthenticated", + "WorkspaceAgentPortShareLevelPublic" + ] + }, + "codersdk.WorkspaceAgentPortShares": { + "type": "object", + "properties": { + "shares": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShare" + } + } + } + }, "codersdk.WorkspaceAgentScript": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 94864971de..c76b9d92a1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -56,6 +56,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/metricscache" + "github.com/coder/coder/v2/coderd/portsharing" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" @@ -400,6 +401,7 @@ func New(options *Options) *API { } api.AppearanceFetcher.Store(&appearance.DefaultFetcher) + api.PortSharer.Store(&portsharing.DefaultPortSharer) api.SiteHandler = site.New(&site.Options{ BinFS: binFS, BinHashes: binHashes, @@ -957,6 +959,14 @@ func New(options *Options) *API { r.Delete("/favorite", api.deleteFavoriteWorkspace) r.Put("/autoupdates", api.putWorkspaceAutoupdates) r.Get("/resolve-autostart", api.resolveAutostart) + r.Route("/port-share", func(r chi.Router) { + r.Use( + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentSharedPorts), + ) + r.Get("/", api.workspaceAgentPortShares) + r.Post("/", api.postWorkspaceAgentPortShare) + r.Delete("/", api.deleteWorkspaceAgentPortShare) + }) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { @@ -1107,6 +1117,7 @@ type API struct { // AccessControlStore is a pointer to an atomic pointer since it is // passed to dbauthz. AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] + PortSharer atomic.Pointer[portsharing.PortSharer] HTTPAuth *HTTPAuthorizer diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3b460e7766..a0da90eb52 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -891,6 +891,20 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { + w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) + if err != nil { + return err + } + + // deleting a workspace port share is more akin to just updating the workspace. + if err = q.authorizeContext(ctx, rbac.ActionUpdate, w.RBACObject()); err != nil { + return xerrors.Errorf("authorize context: %w", err) + } + + return q.db.DeleteWorkspaceAgentPortShare(ctx, arg) +} + func (q *querier) FavoriteWorkspace(ctx context.Context, id uuid.UUID) error { fetch := func(ctx context.Context, id uuid.UUID) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, id) @@ -1868,6 +1882,20 @@ func (q *querier) GetWorkspaceAgentMetadata(ctx context.Context, arg database.Ge return q.db.GetWorkspaceAgentMetadata(ctx, arg) } +func (q *querier) GetWorkspaceAgentPortShare(ctx context.Context, arg database.GetWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) + if err != nil { + return database.WorkspaceAgentPortShare{}, err + } + + // reading a workspace port share is more akin to just reading the workspace. + if err = q.authorizeContext(ctx, rbac.ActionRead, w.RBACObject()); err != nil { + return database.WorkspaceAgentPortShare{}, xerrors.Errorf("authorize context: %w", err) + } + + return q.db.GetWorkspaceAgentPortShare(ctx, arg) +} + func (q *querier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -2500,6 +2528,20 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab return q.db.InsertWorkspaceResourceMetadata(ctx, arg) } +func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { + workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID) + if err != nil { + return nil, err + } + + // listing port shares is more akin to reading the workspace. + if err := q.authorizeContext(ctx, rbac.ActionRead, workspace); err != nil { + return nil, err + } + + return q.db.ListWorkspaceAgentPortShares(ctx, workspaceID) +} + func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { return q.db.GetWorkspaceProxyByID(ctx, arg.ID) @@ -3273,6 +3315,20 @@ func (q *querier) UpsertTailnetTunnel(ctx context.Context, arg database.UpsertTa return q.db.UpsertTailnetTunnel(ctx, arg) } +func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) + if err != nil { + return database.WorkspaceAgentPortShare{}, err + } + + err = q.authorizeContext(ctx, rbac.ActionUpdate, workspace) + if err != nil { + return database.WorkspaceAgentPortShare{}, err + } + + return q.db.UpsertWorkspaceAgentPortShare(ctx, arg) +} + func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) { // TODO Delete this function, all GetTemplates should be authorized. For now just call getTemplates on the authz querier. return q.GetTemplatesWithFilter(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 2441a45348..56b6012ba2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -822,8 +822,9 @@ func (s *MethodTestSuite) TestTemplate() { s.Run("InsertTemplate", s.Subtest(func(db database.Store, check *expects) { orgID := uuid.New() check.Args(database.InsertTemplateParams{ - Provisioner: "echo", - OrganizationID: orgID, + Provisioner: "echo", + OrganizationID: orgID, + MaxPortSharingLevel: database.AppSharingLevelOwner, }).Asserts(rbac.ResourceTemplate.InOrg(orgID), rbac.ActionCreate) })) s.Run("InsertTemplateVersion", s.Subtest(func(db database.Store, check *expects) { @@ -890,7 +891,8 @@ func (s *MethodTestSuite) TestTemplate() { s.Run("UpdateTemplateMetaByID", s.Subtest(func(db database.Store, check *expects) { t1 := dbgen.Template(s.T(), db, database.Template{}) check.Args(database.UpdateTemplateMetaByIDParams{ - ID: t1.ID, + ID: t1.ID, + MaxPortSharingLevel: "owner", }).Asserts(t1, rbac.ActionUpdate) })) s.Run("UpdateTemplateVersionByID", s.Subtest(func(db database.Store, check *expects) { @@ -1601,6 +1603,47 @@ func (s *MethodTestSuite) TestWorkspace() { })) } +func (s *MethodTestSuite) TestWorkspacePortSharing() { + s.Run("UpsertWorkspaceAgentPortShare", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) + ps := dbgen.WorkspaceAgentPortShare(s.T(), db, database.WorkspaceAgentPortShare{WorkspaceID: ws.ID}) + //nolint:gosimple // casting is not a simplification + check.Args(database.UpsertWorkspaceAgentPortShareParams{ + WorkspaceID: ps.WorkspaceID, + AgentName: ps.AgentName, + Port: ps.Port, + ShareLevel: ps.ShareLevel, + }).Asserts(ws, rbac.ActionUpdate).Returns(ps) + })) + s.Run("GetWorkspaceAgentPortShare", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) + ps := dbgen.WorkspaceAgentPortShare(s.T(), db, database.WorkspaceAgentPortShare{WorkspaceID: ws.ID}) + check.Args(database.GetWorkspaceAgentPortShareParams{ + WorkspaceID: ps.WorkspaceID, + AgentName: ps.AgentName, + Port: ps.Port, + }).Asserts(ws, rbac.ActionRead).Returns(ps) + })) + s.Run("ListWorkspaceAgentPortShares", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) + ps := dbgen.WorkspaceAgentPortShare(s.T(), db, database.WorkspaceAgentPortShare{WorkspaceID: ws.ID}) + check.Args(ws.ID).Asserts(ws, rbac.ActionRead).Returns([]database.WorkspaceAgentPortShare{ps}) + })) + s.Run("DeleteWorkspaceAgentPortShare", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID}) + ps := dbgen.WorkspaceAgentPortShare(s.T(), db, database.WorkspaceAgentPortShare{WorkspaceID: ws.ID}) + check.Args(database.DeleteWorkspaceAgentPortShareParams{ + WorkspaceID: ps.WorkspaceID, + AgentName: ps.AgentName, + Port: ps.Port, + }).Asserts(ws, rbac.ActionUpdate).Returns() + })) +} + func (s *MethodTestSuite) TestExtraMethods() { s.Run("GetProvisionerDaemons", s.Subtest(func(db database.Store, check *expects) { d, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index fae32e4878..0c11a511cb 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -90,6 +90,7 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. GroupACL: seed.GroupACL, DisplayName: takeFirst(seed.DisplayName, namesgenerator.GetRandomName(1)), AllowUserCancelWorkspaceJobs: seed.AllowUserCancelWorkspaceJobs, + MaxPortSharingLevel: takeFirst(seed.MaxPortSharingLevel, database.AppSharingLevelOwner), }) require.NoError(t, err, "insert template") @@ -133,6 +134,17 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database return key, fmt.Sprintf("%s-%s", key.ID, secret) } +func WorkspaceAgentPortShare(t testing.TB, db database.Store, orig database.WorkspaceAgentPortShare) database.WorkspaceAgentPortShare { + ps, err := db.UpsertWorkspaceAgentPortShare(genCtx, database.UpsertWorkspaceAgentPortShareParams{ + WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), + AgentName: takeFirst(orig.AgentName, namesgenerator.GetRandomName(1)), + Port: takeFirst(orig.Port, 8080), + ShareLevel: takeFirst(orig.ShareLevel, database.AppSharingLevelPublic), + }) + require.NoError(t, err, "insert workspace agent") + return ps +} + func WorkspaceAgent(t testing.TB, db database.Store, orig database.WorkspaceAgent) database.WorkspaceAgent { agt, err := db.InsertWorkspaceAgent(genCtx, database.InsertWorkspaceAgentParams{ ID: takeFirst(orig.ID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index aac69c0157..a124dad513 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -147,6 +147,7 @@ type data struct { workspaceAgentLogs []database.WorkspaceAgentLog workspaceAgentLogSources []database.WorkspaceAgentLogSource workspaceAgentScripts []database.WorkspaceAgentScript + workspaceAgentPortShares []database.WorkspaceAgentPortShare workspaceApps []database.WorkspaceApp workspaceAppStatsLastInsertID int64 workspaceAppStats []database.WorkspaceAppStat @@ -1322,6 +1323,25 @@ func (*FakeQuerier) DeleteTailnetTunnel(_ context.Context, arg database.DeleteTa return database.DeleteTailnetTunnelRow{}, ErrUnimplemented } +func (q *FakeQuerier) DeleteWorkspaceAgentPortShare(_ context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, share := range q.workspaceAgentPortShares { + if share.WorkspaceID == arg.WorkspaceID && share.AgentName == arg.AgentName && share.Port == arg.Port { + q.workspaceAgentPortShares = append(q.workspaceAgentPortShares[:i], q.workspaceAgentPortShares[i+1:]...) + return nil + } + } + + return nil +} + func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg uuid.UUID) error { err := validateDatabaseType(arg) if err != nil { @@ -4159,6 +4179,24 @@ func (q *FakeQuerier) GetWorkspaceAgentMetadata(_ context.Context, arg database. return metadata, nil } +func (q *FakeQuerier) GetWorkspaceAgentPortShare(_ context.Context, arg database.GetWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WorkspaceAgentPortShare{}, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, share := range q.workspaceAgentPortShares { + if share.WorkspaceID == arg.WorkspaceID && share.AgentName == arg.AgentName && share.Port == arg.Port { + return share, nil + } + } + + return database.WorkspaceAgentPortShare{}, sql.ErrNoRows +} + func (q *FakeQuerier) GetWorkspaceAgentScriptsByAgentIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5374,6 +5412,7 @@ func (q *FakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl AllowUserCancelWorkspaceJobs: arg.AllowUserCancelWorkspaceJobs, AllowUserAutostart: true, AllowUserAutostop: true, + MaxPortSharingLevel: arg.MaxPortSharingLevel, } q.templates = append(q.templates, template) return nil @@ -6006,6 +6045,20 @@ func (q *FakeQuerier) InsertWorkspaceResourceMetadata(_ context.Context, arg dat return metadata, nil } +func (q *FakeQuerier) ListWorkspaceAgentPortShares(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + shares := []database.WorkspaceAgentPortShare{} + for _, share := range q.workspaceAgentPortShares { + if share.WorkspaceID == workspaceID { + shares = append(shares, share) + } + } + + return shares, nil +} + func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -6525,6 +6578,7 @@ func (q *FakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd tpl.Icon = arg.Icon tpl.GroupACL = arg.GroupACL tpl.AllowUserCancelWorkspaceJobs = arg.AllowUserCancelWorkspaceJobs + tpl.MaxPortSharingLevel = arg.MaxPortSharingLevel q.templates[idx] = tpl return nil } @@ -7515,6 +7569,35 @@ func (*FakeQuerier) UpsertTailnetTunnel(_ context.Context, arg database.UpsertTa return database.TailnetTunnel{}, ErrUnimplemented } +func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WorkspaceAgentPortShare{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, share := range q.workspaceAgentPortShares { + if share.WorkspaceID == arg.WorkspaceID && share.Port == arg.Port && share.AgentName == arg.AgentName { + share.ShareLevel = arg.ShareLevel + q.workspaceAgentPortShares[i] = share + return share, nil + } + } + + //nolint:gosimple // casts are not a simplification + psl := database.WorkspaceAgentPortShare{ + WorkspaceID: arg.WorkspaceID, + AgentName: arg.AgentName, + Port: arg.Port, + ShareLevel: arg.ShareLevel, + } + q.workspaceAgentPortShares = append(q.workspaceAgentPortShares, psl) + + return psl, nil +} + func (q *FakeQuerier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 433a1202cc..948de3c763 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -300,6 +300,13 @@ func (m metricsStore) DeleteTailnetTunnel(ctx context.Context, arg database.Dele return r0, r1 } +func (m metricsStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error { + start := time.Now() + r0 := m.s.DeleteWorkspaceAgentPortShare(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteWorkspaceAgentPortShare").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) FavoriteWorkspace(ctx context.Context, arg uuid.UUID) error { start := time.Now() r0 := m.s.FavoriteWorkspace(ctx, arg) @@ -1082,6 +1089,13 @@ func (m metricsStore) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAg return metadata, err } +func (m metricsStore) GetWorkspaceAgentPortShare(ctx context.Context, arg database.GetWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceAgentPortShare(ctx, arg) + m.queryLatencies.WithLabelValues("GetWorkspaceAgentPortShare").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentScriptsByAgentIDs(ctx, ids) @@ -1607,6 +1621,13 @@ func (m metricsStore) InsertWorkspaceResourceMetadata(ctx context.Context, arg d return metadata, err } +func (m metricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { + start := time.Now() + r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID) + m.queryLatencies.WithLabelValues("ListWorkspaceAgentPortShares").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) { start := time.Now() proxy, err := m.s.RegisterWorkspaceProxy(ctx, arg) @@ -2122,6 +2143,13 @@ func (m metricsStore) UpsertTailnetTunnel(ctx context.Context, arg database.Upse return r0, r1 } +func (m metricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + start := time.Now() + r0, r1 := m.s.UpsertWorkspaceAgentPortShare(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertWorkspaceAgentPortShare").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { start := time.Now() templates, err := m.s.GetAuthorizedTemplates(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6c2ae8b942..d767fd7cf5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -500,6 +500,20 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), arg0, arg1) } +// DeleteWorkspaceAgentPortShare mocks base method. +func (m *MockStore) DeleteWorkspaceAgentPortShare(arg0 context.Context, arg1 database.DeleteWorkspaceAgentPortShareParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWorkspaceAgentPortShare", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWorkspaceAgentPortShare indicates an expected call of DeleteWorkspaceAgentPortShare. +func (mr *MockStoreMockRecorder) DeleteWorkspaceAgentPortShare(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceAgentPortShare), arg0, arg1) +} + // FavoriteWorkspace mocks base method. func (m *MockStore) FavoriteWorkspace(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() @@ -2254,6 +2268,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAgentMetadata(arg0, arg1 any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentMetadata", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentMetadata), arg0, arg1) } +// GetWorkspaceAgentPortShare mocks base method. +func (m *MockStore) GetWorkspaceAgentPortShare(arg0 context.Context, arg1 database.GetWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceAgentPortShare", arg0, arg1) + ret0, _ := ret[0].(database.WorkspaceAgentPortShare) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceAgentPortShare indicates an expected call of GetWorkspaceAgentPortShare. +func (mr *MockStoreMockRecorder) GetWorkspaceAgentPortShare(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentPortShare), arg0, arg1) +} + // GetWorkspaceAgentScriptsByAgentIDs mocks base method. func (m *MockStore) GetWorkspaceAgentScriptsByAgentIDs(arg0 context.Context, arg1 []uuid.UUID) ([]database.WorkspaceAgentScript, error) { m.ctrl.T.Helper() @@ -3381,6 +3410,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), arg0, arg1) } +// ListWorkspaceAgentPortShares mocks base method. +func (m *MockStore) ListWorkspaceAgentPortShares(arg0 context.Context, arg1 uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListWorkspaceAgentPortShares", arg0, arg1) + ret0, _ := ret[0].([]database.WorkspaceAgentPortShare) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListWorkspaceAgentPortShares indicates an expected call of ListWorkspaceAgentPortShares. +func (mr *MockStoreMockRecorder) ListWorkspaceAgentPortShares(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceAgentPortShares", reflect.TypeOf((*MockStore)(nil).ListWorkspaceAgentPortShares), arg0, arg1) +} + // Ping mocks base method. func (m *MockStore) Ping(arg0 context.Context) (time.Duration, error) { m.ctrl.T.Helper() @@ -4460,6 +4504,21 @@ func (mr *MockStoreMockRecorder) UpsertTailnetTunnel(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTailnetTunnel", reflect.TypeOf((*MockStore)(nil).UpsertTailnetTunnel), arg0, arg1) } +// UpsertWorkspaceAgentPortShare mocks base method. +func (m *MockStore) UpsertWorkspaceAgentPortShare(arg0 context.Context, arg1 database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertWorkspaceAgentPortShare", arg0, arg1) + ret0, _ := ret[0].(database.WorkspaceAgentPortShare) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertWorkspaceAgentPortShare indicates an expected call of UpsertWorkspaceAgentPortShare. +func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentPortShare(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentPortShare), arg0, arg1) +} + // Wrappers mocks base method. func (m *MockStore) Wrappers() []string { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index eae49aba34..07ce232c02 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -850,7 +850,8 @@ CREATE TABLE templates ( require_active_version boolean DEFAULT false NOT NULL, deprecated text DEFAULT ''::text NOT NULL, use_max_ttl boolean DEFAULT false NOT NULL, - activity_bump bigint DEFAULT '3600000000000'::bigint NOT NULL + activity_bump bigint DEFAULT '3600000000000'::bigint NOT NULL, + max_port_sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -901,6 +902,7 @@ CREATE VIEW template_with_users AS templates.deprecated, templates.use_max_ttl, templates.activity_bump, + templates.max_port_sharing_level, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username FROM (public.templates @@ -958,6 +960,13 @@ CREATE UNLOGGED TABLE workspace_agent_metadata ( COMMENT ON COLUMN workspace_agent_metadata.display_order IS 'Specifies the order in which to display agent metadata in user interfaces.'; +CREATE TABLE workspace_agent_port_share ( + workspace_id uuid NOT NULL, + agent_name text NOT NULL, + port integer NOT NULL, + share_level app_sharing_level NOT NULL +); + CREATE TABLE workspace_agent_scripts ( workspace_agent_id uuid NOT NULL, log_source_id uuid NOT NULL, @@ -1405,6 +1414,9 @@ ALTER TABLE ONLY workspace_agent_log_sources ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); +ALTER TABLE ONLY workspace_agent_port_share + ADD CONSTRAINT workspace_agent_port_share_pkey PRIMARY KEY (workspace_id, agent_name, port); + ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); @@ -1631,6 +1643,9 @@ ALTER TABLE ONLY workspace_agent_log_sources ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_agent_port_share + ADD CONSTRAINT workspace_agent_port_share_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index f5ecbe0d15..8428d48968 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -38,6 +38,7 @@ const ( ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentMetadataWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_metadata_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentPortShareWorkspaceID ForeignKeyConstraint = "workspace_agent_port_share_workspace_id_fkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentScriptsWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_scripts_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentStartupLogsAgentID ForeignKeyConstraint = "workspace_agent_startup_logs_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentsResourceID ForeignKeyConstraint = "workspace_agents_resource_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000191_workspace_agent_port_sharing.down.sql b/coderd/database/migrations/000191_workspace_agent_port_sharing.down.sql new file mode 100644 index 0000000000..1fe22e4f8d --- /dev/null +++ b/coderd/database/migrations/000191_workspace_agent_port_sharing.down.sql @@ -0,0 +1,20 @@ +DROP TABLE workspace_agent_port_share; +DROP VIEW template_with_users; +ALTER TABLE templates DROP COLUMN max_port_sharing_level; + +-- Update the template_with_users view by recreating it. + +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; diff --git a/coderd/database/migrations/000191_workspace_agent_port_sharing.up.sql b/coderd/database/migrations/000191_workspace_agent_port_sharing.up.sql new file mode 100644 index 0000000000..3df9e8eee0 --- /dev/null +++ b/coderd/database/migrations/000191_workspace_agent_port_sharing.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE workspace_agent_port_share ( + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + agent_name text NOT NULL, + port integer NOT NULL, + share_level app_sharing_level NOT NULL +); + +ALTER TABLE workspace_agent_port_share ADD PRIMARY KEY (workspace_id, agent_name, port); + +ALTER TABLE templates ADD COLUMN max_port_sharing_level app_sharing_level NOT NULL DEFAULT 'owner'::app_sharing_level; + +-- Update the template_with_users view by recreating it. +DROP VIEW template_with_users; +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; diff --git a/coderd/database/migrations/testdata/fixtures/000191_ workspace_agent_port_share.up.sql b/coderd/database/migrations/testdata/fixtures/000191_ workspace_agent_port_share.up.sql new file mode 100644 index 0000000000..318f2b5fcd --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000191_ workspace_agent_port_share.up.sql @@ -0,0 +1,4 @@ +INSERT INTO workspace_agent_port_share + (workspace_id, agent_name, port, share_level) +VALUES + ('b90547be-8870-4d68-8184-e8b2242b7c01', 'qua', 8080, 'public'::app_sharing_level) RETURNING *; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index b78751fecf..34d43ecd92 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -91,6 +91,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.Deprecated, &i.UseMaxTtl, &i.ActivityBump, + &i.MaxPortSharingLevel, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { diff --git a/coderd/database/models.go b/coderd/database/models.go index cf811b10a6..f1b81d1170 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2004,6 +2004,7 @@ type Template struct { Deprecated string `db:"deprecated" json:"deprecated"` UseMaxTtl bool `db:"use_max_ttl" json:"use_max_ttl"` ActivityBump int64 `db:"activity_bump" json:"activity_bump"` + MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` } @@ -2044,9 +2045,10 @@ type TemplateTable struct { AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` // If set to a non empty string, the template will no longer be able to be used. The message will be displayed to the user. - Deprecated string `db:"deprecated" json:"deprecated"` - UseMaxTtl bool `db:"use_max_ttl" json:"use_max_ttl"` - ActivityBump int64 `db:"activity_bump" json:"activity_bump"` + Deprecated string `db:"deprecated" json:"deprecated"` + UseMaxTtl bool `db:"use_max_ttl" json:"use_max_ttl"` + ActivityBump int64 `db:"activity_bump" json:"activity_bump"` + MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` } // Joins in the username + avatar url of the created by user. @@ -2274,6 +2276,13 @@ type WorkspaceAgentMetadatum struct { DisplayOrder int32 `db:"display_order" json:"display_order"` } +type WorkspaceAgentPortShare struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentName string `db:"agent_name" json:"agent_name"` + Port int32 `db:"port" json:"port"` + ShareLevel AppSharingLevel `db:"share_level" json:"share_level"` +} + type WorkspaceAgentScript struct { WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` LogSourceID uuid.UUID `db:"log_source_id" json:"log_source_id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 26a71d4b93..cbeb5b1caf 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -77,6 +77,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error FavoriteWorkspace(ctx context.Context, id uuid.UUID) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names @@ -227,6 +228,7 @@ type sqlcQuerier interface { GetWorkspaceAgentLogSourcesByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentLogSource, error) GetWorkspaceAgentLogsAfter(ctx context.Context, arg GetWorkspaceAgentLogsAfterParams) ([]WorkspaceAgentLog, error) GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorkspaceAgentMetadataParams) ([]WorkspaceAgentMetadatum, error) + GetWorkspaceAgentPortShare(ctx context.Context, arg GetWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error) GetWorkspaceAgentStatsAndLabels(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsAndLabelsRow, error) @@ -317,6 +319,7 @@ type sqlcQuerier interface { InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) + ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error // Non blocking lock. Returns true if the lock was acquired, false otherwise. @@ -400,6 +403,7 @@ type sqlcQuerier interface { UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (TailnetCoordinator, error) UpsertTailnetPeer(ctx context.Context, arg UpsertTailnetPeerParams) (TailnetPeer, error) UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetTunnelParams) (TailnetTunnel, error) + UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) } var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2fd9044efd..c4ba338c7f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5728,7 +5728,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, max_port_sharing_level, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -5770,6 +5770,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.Deprecated, &i.UseMaxTtl, &i.ActivityBump, + &i.MaxPortSharingLevel, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -5778,7 +5779,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, max_port_sharing_level, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -5828,6 +5829,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.Deprecated, &i.UseMaxTtl, &i.ActivityBump, + &i.MaxPortSharingLevel, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -5835,7 +5837,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, created_by_avatar_url, created_by_username FROM template_with_users AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, max_port_sharing_level, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -5878,6 +5880,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.Deprecated, &i.UseMaxTtl, &i.ActivityBump, + &i.MaxPortSharingLevel, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5896,7 +5899,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, max_port_sharing_level, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -5989,6 +5992,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.Deprecated, &i.UseMaxTtl, &i.ActivityBump, + &i.MaxPortSharingLevel, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -6021,10 +6025,11 @@ INSERT INTO user_acl, group_acl, display_name, - allow_user_cancel_workspace_jobs + allow_user_cancel_workspace_jobs, + max_port_sharing_level ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ` type InsertTemplateParams struct { @@ -6042,6 +6047,7 @@ type InsertTemplateParams struct { GroupACL TemplateACL `db:"group_acl" json:"group_acl"` DisplayName string `db:"display_name" json:"display_name"` AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) error { @@ -6060,6 +6066,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.GroupACL, arg.DisplayName, arg.AllowUserCancelWorkspaceJobs, + arg.MaxPortSharingLevel, ) return err } @@ -6158,20 +6165,22 @@ SET icon = $5, display_name = $6, allow_user_cancel_workspace_jobs = $7, - group_acl = $8 + group_acl = $8, + max_port_sharing_level = $9 WHERE id = $1 ` type UpdateTemplateMetaByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Description string `db:"description" json:"description"` - Name string `db:"name" json:"name"` - Icon string `db:"icon" json:"icon"` - DisplayName string `db:"display_name" json:"display_name"` - AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` - GroupACL TemplateACL `db:"group_acl" json:"group_acl"` + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Description string `db:"description" json:"description"` + Name string `db:"name" json:"name"` + Icon string `db:"icon" json:"icon"` + DisplayName string `db:"display_name" json:"display_name"` + AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + GroupACL TemplateACL `db:"group_acl" json:"group_acl"` + MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"` } func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error { @@ -6184,6 +6193,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.DisplayName, arg.AllowUserCancelWorkspaceJobs, arg.GroupACL, + arg.MaxPortSharingLevel, ) return err } @@ -8141,6 +8151,105 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP return i, err } +const deleteWorkspaceAgentPortShare = `-- name: DeleteWorkspaceAgentPortShare :exec +DELETE FROM workspace_agent_port_share WHERE workspace_id = $1 AND agent_name = $2 AND port = $3 +` + +type DeleteWorkspaceAgentPortShareParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentName string `db:"agent_name" json:"agent_name"` + Port int32 `db:"port" json:"port"` +} + +func (q *sqlQuerier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error { + _, err := q.db.ExecContext(ctx, deleteWorkspaceAgentPortShare, arg.WorkspaceID, arg.AgentName, arg.Port) + return err +} + +const getWorkspaceAgentPortShare = `-- name: GetWorkspaceAgentPortShare :one +SELECT workspace_id, agent_name, port, share_level FROM workspace_agent_port_share WHERE workspace_id = $1 AND agent_name = $2 AND port = $3 +` + +type GetWorkspaceAgentPortShareParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentName string `db:"agent_name" json:"agent_name"` + Port int32 `db:"port" json:"port"` +} + +func (q *sqlQuerier) GetWorkspaceAgentPortShare(ctx context.Context, arg GetWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceAgentPortShare, arg.WorkspaceID, arg.AgentName, arg.Port) + var i WorkspaceAgentPortShare + err := row.Scan( + &i.WorkspaceID, + &i.AgentName, + &i.Port, + &i.ShareLevel, + ) + return i, err +} + +const listWorkspaceAgentPortShares = `-- name: ListWorkspaceAgentPortShares :many +SELECT workspace_id, agent_name, port, share_level FROM workspace_agent_port_share WHERE workspace_id = $1 +` + +func (q *sqlQuerier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) { + rows, err := q.db.QueryContext(ctx, listWorkspaceAgentPortShares, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentPortShare + for rows.Next() { + var i WorkspaceAgentPortShare + if err := rows.Scan( + &i.WorkspaceID, + &i.AgentName, + &i.Port, + &i.ShareLevel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertWorkspaceAgentPortShare = `-- name: UpsertWorkspaceAgentPortShare :one +INSERT INTO workspace_agent_port_share (workspace_id, agent_name, port, share_level) +VALUES ($1, $2, $3, $4) +ON CONFLICT (workspace_id, agent_name, port) DO UPDATE SET share_level = $4 RETURNING workspace_id, agent_name, port, share_level +` + +type UpsertWorkspaceAgentPortShareParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentName string `db:"agent_name" json:"agent_name"` + Port int32 `db:"port" json:"port"` + ShareLevel AppSharingLevel `db:"share_level" json:"share_level"` +} + +func (q *sqlQuerier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) { + row := q.db.QueryRowContext(ctx, upsertWorkspaceAgentPortShare, + arg.WorkspaceID, + arg.AgentName, + arg.Port, + arg.ShareLevel, + ) + var i WorkspaceAgentPortShare + err := row.Scan( + &i.WorkspaceID, + &i.AgentName, + &i.Port, + &i.ShareLevel, + ) + return i, err +} + const deleteOldWorkspaceAgentLogs = `-- name: DeleteOldWorkspaceAgentLogs :exec DELETE FROM workspace_agent_logs WHERE agent_id IN (SELECT id FROM workspace_agents WHERE last_connected_at IS NOT NULL @@ -11378,7 +11487,7 @@ LEFT JOIN LATERAL ( ) latest_build ON TRUE LEFT JOIN LATERAL ( SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl, activity_bump, max_port_sharing_level FROM templates WHERE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index ef540e37eb..2202c1b710 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -83,10 +83,11 @@ INSERT INTO user_acl, group_acl, display_name, - allow_user_cancel_workspace_jobs + allow_user_cancel_workspace_jobs, + max_port_sharing_level ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15); -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -116,7 +117,8 @@ SET icon = $5, display_name = $6, allow_user_cancel_workspace_jobs = $7, - group_acl = $8 + group_acl = $8, + max_port_sharing_level = $9 WHERE id = $1 ; diff --git a/coderd/database/queries/workspaceagentportshare.sql b/coderd/database/queries/workspaceagentportshare.sql new file mode 100644 index 0000000000..f19da14688 --- /dev/null +++ b/coderd/database/queries/workspaceagentportshare.sql @@ -0,0 +1,13 @@ +-- name: GetWorkspaceAgentPortShare :one +SELECT * FROM workspace_agent_port_share WHERE workspace_id = $1 AND agent_name = $2 AND port = $3; + +-- name: ListWorkspaceAgentPortShares :many +SELECT * FROM workspace_agent_port_share WHERE workspace_id = $1; + +-- name: DeleteWorkspaceAgentPortShare :exec +DELETE FROM workspace_agent_port_share WHERE workspace_id = $1 AND agent_name = $2 AND port = $3; + +-- name: UpsertWorkspaceAgentPortShare :one +INSERT INTO workspace_agent_port_share (workspace_id, agent_name, port, share_level) +VALUES ($1, $2, $3, $4) +ON CONFLICT (workspace_id, agent_name, port) DO UPDATE SET share_level = $4 RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index af9e7b3cbf..747aa3e07b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -51,6 +51,7 @@ const ( UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); + UniqueWorkspaceAgentPortSharePkey UniqueConstraint = "workspace_agent_port_share_pkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_pkey PRIMARY KEY (workspace_id, agent_name, port); UniqueWorkspaceAgentStartupLogsPkey UniqueConstraint = "workspace_agent_startup_logs_pkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); UniqueWorkspaceAgentsPkey UniqueConstraint = "workspace_agents_pkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); diff --git a/coderd/httpmw/experiments.go b/coderd/httpmw/experiments.go new file mode 100644 index 0000000000..7c802725b9 --- /dev/null +++ b/coderd/httpmw/experiments.go @@ -0,0 +1,23 @@ +package httpmw + +import ( + "fmt" + "net/http" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +func RequireExperiment(experiments codersdk.Experiments, experiment codersdk.Experiment) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !experiments.Enabled(experiment) { + httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{ + Message: fmt.Sprintf("Experiment '%s' is required but not enabled", experiment), + }) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index a362a6a86d..4c235dec15 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -420,8 +420,9 @@ func TestCache_BuildTime(t *testing.T) { id := uuid.New() err := db.InsertTemplate(ctx, database.InsertTemplateParams{ - ID: id, - Provisioner: database.ProvisionerTypeEcho, + ID: id, + Provisioner: database.ProvisionerTypeEcho, + MaxPortSharingLevel: database.AppSharingLevelOwner, }) require.NoError(t, err) template, err := db.GetTemplateByID(ctx, id) diff --git a/coderd/portsharing/portsharing.go b/coderd/portsharing/portsharing.go new file mode 100644 index 0000000000..9c05539b5a --- /dev/null +++ b/coderd/portsharing/portsharing.go @@ -0,0 +1,25 @@ +package portsharing + +import ( + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +type PortSharer interface { + AuthorizedPortSharingLevel(template database.Template, level codersdk.WorkspaceAgentPortShareLevel) error + ValidateTemplateMaxPortSharingLevel(level codersdk.WorkspaceAgentPortShareLevel) error +} + +type AGPLPortSharer struct{} + +func (AGPLPortSharer) AuthorizedPortSharingLevel(_ database.Template, _ codersdk.WorkspaceAgentPortShareLevel) error { + return nil +} + +func (AGPLPortSharer) ValidateTemplateMaxPortSharingLevel(_ codersdk.WorkspaceAgentPortShareLevel) error { + return xerrors.New("Restricting port sharing level is an enterprise feature that is not enabled.") +} + +var DefaultPortSharer PortSharer = AGPLPortSharer{} diff --git a/coderd/templates.go b/coderd/templates.go index ccbafde06d..7b23c910fa 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -350,6 +350,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque DisplayName: createTemplate.DisplayName, Icon: createTemplate.Icon, AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, + MaxPortSharingLevel: database.AppSharingLevelOwner, }) if err != nil { return xerrors.Errorf("insert template: %s", err) @@ -539,6 +540,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { ctx = r.Context() template = httpmw.TemplateParam(r) auditor = *api.Auditor.Load() + portSharer = *api.PortSharer.Load() aReq, commitAudit = audit.InitRequest[database.Template](rw, &audit.RequestParams{ Audit: auditor, Log: api.Logger, @@ -638,6 +640,15 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.TimeTilDormantAutoDeleteMillis < 0 || (req.TimeTilDormantAutoDeleteMillis > 0 && req.TimeTilDormantAutoDeleteMillis < minTTL) { validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodelete_ms", Detail: "Value must be at least one minute."}) } + maxPortShareLevel := template.MaxPortSharingLevel + if req.MaxPortShareLevel != nil && *req.MaxPortShareLevel != codersdk.WorkspaceAgentPortShareLevel(maxPortShareLevel) { + err := portSharer.ValidateTemplateMaxPortSharingLevel(*req.MaxPortShareLevel) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_port_sharing_level", Detail: err.Error()}) + } else { + maxPortShareLevel = database.AppSharingLevel(*req.MaxPortShareLevel) + } + } if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -667,7 +678,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() && req.RequireActiveVersion == template.RequireActiveVersion && - (deprecationMessage == template.Deprecated) { + (deprecationMessage == template.Deprecated) && + maxPortShareLevel == template.MaxPortSharingLevel { return nil } @@ -692,6 +704,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { Icon: req.Icon, AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs, GroupACL: groupACL, + MaxPortSharingLevel: maxPortShareLevel, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) @@ -910,5 +923,6 @@ func (api *API) convertTemplate( RequireActiveVersion: templateAccessControl.RequireActiveVersion, Deprecated: templateAccessControl.IsDeprecated(), DeprecationMessage: templateAccessControl.Deprecated, + MaxPortShareLevel: codersdk.WorkspaceAgentPortShareLevel(template.MaxPortSharingLevel), } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 0972abc201..c7366d4f9b 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -625,6 +625,36 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Empty(t, updated.DeprecationMessage) }) + t.Run("AGPL_MaxPortShareLevel", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: false}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Equal(t, codersdk.WorkspaceAgentPortShareLevelOwner, template.MaxPortShareLevel) + + var level codersdk.WorkspaceAgentPortShareLevel = codersdk.WorkspaceAgentPortShareLevelPublic + req := codersdk.UpdateTemplateMeta{ + MaxPortShareLevel: &level, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, req) + // AGPL cannot change max port sharing level + require.ErrorContains(t, err, "port sharing level is an enterprise feature") + + // Ensure the same value port share level is a no-op + level = codersdk.WorkspaceAgentPortShareLevelOwner + _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name + "2", + MaxPortShareLevel: &level, + }) + require.NoError(t, err) + }) + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceagentportshare.go b/coderd/workspaceagentportshare.go new file mode 100644 index 0000000000..545a417368 --- /dev/null +++ b/coderd/workspaceagentportshare.go @@ -0,0 +1,173 @@ +package coderd + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Upsert workspace agent port share +// @ID upsert-workspace-agent-port-share +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags PortSharing +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param request body codersdk.UpsertWorkspaceAgentPortShareRequest true "Upsert port sharing level request" +// @Success 200 {object} codersdk.WorkspaceAgentPortShare +// @Router /workspaces/{workspace}/port-share [post] +func (api *API) postWorkspaceAgentPortShare(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + portSharer := *api.PortSharer.Load() + var req codersdk.UpsertWorkspaceAgentPortShareRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if !req.ShareLevel.ValidPortShareLevel() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Port sharing level not allowed.", + }) + return + } + + template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + err = portSharer.AuthorizedPortSharingLevel(template, req.ShareLevel) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: err.Error(), + }) + return + } + + agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + found := false + for _, agent := range agents { + if agent.Name == req.AgentName { + found = true + break + } + } + if !found { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Agent not found.", + }) + return + } + + psl, err := api.Database.UpsertWorkspaceAgentPortShare(ctx, database.UpsertWorkspaceAgentPortShareParams{ + WorkspaceID: workspace.ID, + AgentName: req.AgentName, + Port: req.Port, + ShareLevel: database.AppSharingLevel(req.ShareLevel), + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertPortShare(psl)) +} + +// @Summary Get workspace agent port shares +// @ID get-workspace-agent-port-shares +// @Security CoderSessionToken +// @Produce json +// @Tags PortSharing +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceAgentPortShares +// @Router /workspaces/{workspace}/port-share [get] +func (api *API) workspaceAgentPortShares(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + + shares, err := api.Database.ListWorkspaceAgentPortShares(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentPortShares{ + Shares: convertPortShares(shares), + }) +} + +// @Summary Get workspace agent port shares +// @ID get-workspace-agent-port-shares +// @Security CoderSessionToken +// @Accept json +// @Tags PortSharing +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param request body codersdk.DeleteWorkspaceAgentPortShareRequest true "Delete port sharing level request" +// @Success 200 +// @Router /workspaces/{workspace}/port-share [delete] +func (api *API) deleteWorkspaceAgentPortShare(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + var req codersdk.DeleteWorkspaceAgentPortShareRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + _, err := api.Database.GetWorkspaceAgentPortShare(ctx, database.GetWorkspaceAgentPortShareParams{ + WorkspaceID: workspace.ID, + AgentName: req.AgentName, + Port: req.Port, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Port share not found.", + }) + return + } + + httpapi.InternalServerError(rw, err) + return + } + + err = api.Database.DeleteWorkspaceAgentPortShare(ctx, database.DeleteWorkspaceAgentPortShareParams{ + WorkspaceID: workspace.ID, + AgentName: req.AgentName, + Port: req.Port, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + rw.WriteHeader(http.StatusOK) +} + +func convertPortShares(shares []database.WorkspaceAgentPortShare) []codersdk.WorkspaceAgentPortShare { + var converted []codersdk.WorkspaceAgentPortShare + for _, share := range shares { + converted = append(converted, convertPortShare(share)) + } + return converted +} + +func convertPortShare(share database.WorkspaceAgentPortShare) codersdk.WorkspaceAgentPortShare { + return codersdk.WorkspaceAgentPortShare{ + WorkspaceID: share.WorkspaceID, + AgentName: share.AgentName, + Port: share.Port, + ShareLevel: codersdk.WorkspaceAgentPortShareLevel(share.ShareLevel), + } +} diff --git a/coderd/workspaceagentportshare_test.go b/coderd/workspaceagentportshare_test.go new file mode 100644 index 0000000000..eb335d47bf --- /dev/null +++ b/coderd/workspaceagentportshare_test.go @@ -0,0 +1,168 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestPostWorkspaceAgentPortShare(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + dep := coderdtest.DeploymentValues(t) + dep.Experiments = append(dep.Experiments, string(codersdk.ExperimentSharedPorts)) + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dep, + }) + owner := coderdtest.CreateFirstUser(t, ownerClient) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + tmpDir := t.TempDir() + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: owner.OrganizationID, + OwnerID: user.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(user, owner.OrganizationID)), r.Workspace.ID) + require.NoError(t, err) + + // owner level should fail + _, err = client.UpsertWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevel("owner"), + }) + require.Error(t, err) + + // invalid level should fail + _, err = client.UpsertWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevel("invalid"), + }) + require.Error(t, err) + + // OK, ignoring template max port share level because we are AGPL + ps, err := client.UpsertWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + }) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelPublic, ps.ShareLevel) + + // update share level + ps, err = client.UpsertWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, + }) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelAuthenticated, ps.ShareLevel) +} + +func TestGetWorkspaceAgentPortShares(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + dep := coderdtest.DeploymentValues(t) + dep.Experiments = append(dep.Experiments, string(codersdk.ExperimentSharedPorts)) + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dep, + }) + owner := coderdtest.CreateFirstUser(t, ownerClient) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + tmpDir := t.TempDir() + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: owner.OrganizationID, + OwnerID: user.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(user, owner.OrganizationID)), r.Workspace.ID) + require.NoError(t, err) + + _, err = client.UpsertWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + }) + require.NoError(t, err) + + ps, err := client.GetWorkspaceAgentPortShares(ctx, r.Workspace.ID) + require.NoError(t, err) + require.Len(t, ps.Shares, 1) + require.EqualValues(t, agents[0].Name, ps.Shares[0].AgentName) + require.EqualValues(t, 8080, ps.Shares[0].Port) + require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelPublic, ps.Shares[0].ShareLevel) +} + +func TestDeleteWorkspaceAgentPortShare(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + dep := coderdtest.DeploymentValues(t) + dep.Experiments = append(dep.Experiments, string(codersdk.ExperimentSharedPorts)) + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dep, + }) + owner := coderdtest.CreateFirstUser(t, ownerClient) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + tmpDir := t.TempDir() + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: owner.OrganizationID, + OwnerID: user.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(user, owner.OrganizationID)), r.Workspace.ID) + require.NoError(t, err) + + // create + ps, err := client.UpsertWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + }) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelPublic, ps.ShareLevel) + + // delete + err = client.DeleteWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.DeleteWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + }) + require.NoError(t, err) + + // delete missing + err = client.DeleteWorkspaceAgentPortShare(ctx, r.Workspace.ID, codersdk.DeleteWorkspaceAgentPortShareRequest{ + AgentName: agents[0].Name, + Port: 8080, + }) + require.Error(t, err) + + _, err = db.GetWorkspaceAgentPortShare(dbauthz.As(ctx, coderdtest.AuthzUserSubject(user, owner.OrganizationID)), database.GetWorkspaceAgentPortShareParams{ + WorkspaceID: r.Workspace.ID, + AgentName: agents[0].Name, + Port: 8080, + }) + require.Error(t, err) +} diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 2c4963060b..d72cf522d2 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -909,6 +909,79 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { require.Equal(t, http.StatusOK, resp.StatusCode) }) + t.Run("PortSharingNoShare", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + userAppClient := appDetails.AppClient(t) + userAppClient.SetSessionToken(userClient.SessionToken()) + + resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("PortSharingAuthenticatedOK", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // we are shadowing the parent since we are changing the state + appDetails := setupProxyTest(t, nil) + + port, err := strconv.ParseInt(appDetails.Apps.Port.AppSlugOrPort, 10, 32) + require.NoError(t, err) + // set the port we have to be shared with authenticated users + _, err = appDetails.SDKClient.UpsertWorkspaceAgentPortShare(ctx, appDetails.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: proxyTestAgentName, + Port: int32(port), + ShareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, + }) + require.NoError(t, err) + + userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + userAppClient := appDetails.AppClient(t) + userAppClient.SetSessionToken(userClient.SessionToken()) + + resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("PortSharingPublicOK", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // we are shadowing the parent since we are changing the state + appDetails := setupProxyTest(t, nil) + + port, err := strconv.ParseInt(appDetails.Apps.Port.AppSlugOrPort, 10, 32) + require.NoError(t, err) + // set the port we have to be shared with public + _, err = appDetails.SDKClient.UpsertWorkspaceAgentPortShare(ctx, appDetails.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: proxyTestAgentName, + Port: int32(port), + ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + }) + require.NoError(t, err) + + publicAppClient := appDetails.AppClient(t) + publicAppClient.SetSessionToken("") + + resp, err := requestWithRetries(ctx, t, publicAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + t.Run("ProxyError", func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/request.go b/coderd/workspaceapps/request.go index a6eb12c145..2c10ac3687 100644 --- a/coderd/workspaceapps/request.go +++ b/coderd/workspaceapps/request.go @@ -3,6 +3,7 @@ package workspaceapps import ( "context" "database/sql" + "errors" "fmt" "net/url" "strconv" @@ -313,6 +314,36 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR // This is only supported for subdomain-based applications. appURL = fmt.Sprintf("http://127.0.0.1:%d", portUint) appSharingLevel = database.AppSharingLevelOwner + + // Port sharing authorization + agentName := agentNameOrID + id, err := uuid.Parse(agentNameOrID) + for _, a := range agents { + // if err is nil then it's an UUID + if err == nil && a.ID == id { + agentName = a.Name + break + } + // otherwise it's a name + if a.Name == agentNameOrID { + break + } + } + + // First check if there is a port share for the port + ps, err := db.GetWorkspaceAgentPortShare(ctx, database.GetWorkspaceAgentPortShareParams{ + WorkspaceID: workspace.ID, + AgentName: agentName, + Port: int32(portUint), + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get workspace agent port share: %w", err) + } + // No port share found, so we keep default to owner. + } else { + appSharingLevel = ps.ShareLevel + } } else { for _, app := range apps { if app.Slug == r.AppSlugOrPort { diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 341cc3bc56..9ca439da0a 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -257,6 +257,7 @@ func TestWorkspaceApps(t *testing.T) { deploymentValues.DisablePathApps = clibase.Bool(opts.DisablePathApps) deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing) deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess) + deploymentValues.Experiments = append(deploymentValues.Experiments, string(codersdk.ExperimentSharedPorts)) if opts.DisableSubdomainApps { opts.AppHost = "" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 6bc115ae5b..63299e9313 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -52,6 +52,7 @@ const ( FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" FeatureAccessControl FeatureName = "access_control" FeatureOAuth2Provider FeatureName = "oauth2_provider" + FeatureControlSharedPorts FeatureName = "control_shared_ports" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -72,6 +73,7 @@ var FeatureNames = []FeatureName{ FeatureWorkspaceBatchActions, FeatureAccessControl, FeatureOAuth2Provider, + FeatureControlSharedPorts, } // Humanize returns the feature name in a human-readable format. @@ -2115,14 +2117,17 @@ type Experiment string const ( // Add new experiments here! - ExperimentExample Experiment = "example" // This isn't used for anything. + ExperimentExample Experiment = "example" // This isn't used for anything. + ExperimentSharedPorts Experiment = "shared-ports" ) // 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. -var ExperimentsAll = Experiments{} +var ExperimentsAll = Experiments{ + ExperimentSharedPorts, +} // Experiments is a list of experiments. // Multiple experiments may be enabled at the same time. diff --git a/codersdk/templates.go b/codersdk/templates.go index df879ebfc3..c23c3cd46c 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -61,7 +61,8 @@ type Template struct { // RequireActiveVersion mandates that workspaces are built with the active // template version. - RequireActiveVersion bool `json:"require_active_version"` + RequireActiveVersion bool `json:"require_active_version"` + MaxPortShareLevel WorkspaceAgentPortShareLevel `json:"max_port_share_level"` } // WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with @@ -251,7 +252,8 @@ type UpdateTemplateMeta struct { // If this is set to true, the template will not be available to all users, // and must be explicitly granted to users or groups in the permissions settings // of the template. - DisableEveryoneGroupAccess bool `json:"disable_everyone_group_access"` + DisableEveryoneGroupAccess bool `json:"disable_everyone_group_access"` + MaxPortShareLevel *WorkspaceAgentPortShareLevel `json:"max_port_share_level"` } type TemplateExample struct { diff --git a/codersdk/workspaceagentportshare.go b/codersdk/workspaceagentportshare.go new file mode 100644 index 0000000000..f5acf276ea --- /dev/null +++ b/codersdk/workspaceagentportshare.go @@ -0,0 +1,89 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" +) + +const ( + WorkspaceAgentPortShareLevelOwner WorkspaceAgentPortShareLevel = "owner" + WorkspaceAgentPortShareLevelAuthenticated WorkspaceAgentPortShareLevel = "authenticated" + WorkspaceAgentPortShareLevelPublic WorkspaceAgentPortShareLevel = "public" +) + +type ( + WorkspaceAgentPortShareLevel string + UpsertWorkspaceAgentPortShareRequest struct { + AgentName string `json:"agent_name"` + Port int32 `json:"port"` + ShareLevel WorkspaceAgentPortShareLevel `json:"share_level"` + } + WorkspaceAgentPortShares struct { + Shares []WorkspaceAgentPortShare `json:"shares"` + } + WorkspaceAgentPortShare struct { + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + AgentName string `json:"agent_name"` + Port int32 `json:"port"` + ShareLevel WorkspaceAgentPortShareLevel `json:"share_level"` + } + DeleteWorkspaceAgentPortShareRequest struct { + AgentName string `json:"agent_name"` + Port int32 `json:"port"` + } +) + +func (l WorkspaceAgentPortShareLevel) ValidMaxLevel() bool { + return l == WorkspaceAgentPortShareLevelOwner || + l == WorkspaceAgentPortShareLevelAuthenticated || + l == WorkspaceAgentPortShareLevelPublic +} + +func (l WorkspaceAgentPortShareLevel) ValidPortShareLevel() bool { + return l == WorkspaceAgentPortShareLevelAuthenticated || + l == WorkspaceAgentPortShareLevelPublic +} + +func (c *Client) GetWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) (WorkspaceAgentPortShares, error) { + var shares WorkspaceAgentPortShares + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/port-share", workspaceID), nil) + if err != nil { + return shares, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return shares, ReadBodyAsError(res) + } + + return shares, json.NewDecoder(res.Body).Decode(&shares) +} + +func (c *Client) UpsertWorkspaceAgentPortShare(ctx context.Context, workspaceID uuid.UUID, req UpsertWorkspaceAgentPortShareRequest) (WorkspaceAgentPortShare, error) { + var share WorkspaceAgentPortShare + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/port-share", workspaceID), req) + if err != nil { + return share, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return share, ReadBodyAsError(res) + } + + return share, json.NewDecoder(res.Body).Decode(&share) +} + +func (c *Client) DeleteWorkspaceAgentPortShare(ctx context.Context, workspaceID uuid.UUID, req DeleteWorkspaceAgentPortShareRequest) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/port-share", workspaceID), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 3e19f6bf43..8d7f638dca 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -8,20 +8,20 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_max_ttltrue
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
nametrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_max_ttltrue
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
nametrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/docs/api/portsharing.md b/docs/api/portsharing.md new file mode 100644 index 0000000000..5b2de111c2 --- /dev/null +++ b/docs/api/portsharing.md @@ -0,0 +1,90 @@ +# PortSharing + +## Get workspace agent port shares + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/workspaces/{workspace}/port-share \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /workspaces/{workspace}/port-share` + +> Body parameter + +```json +{ + "agent_name": "string", + "port": 0 +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | -------------------------------------------------------------------------------------------------------- | -------- | --------------------------------- | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.DeleteWorkspaceAgentPortShareRequest](schemas.md#codersdkdeleteworkspaceagentportsharerequest) | true | Delete port sharing level request | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert workspace agent port share + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/port-share \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /workspaces/{workspace}/port-share` + +> Body parameter + +```json +{ + "agent_name": "string", + "port": 0, + "share_level": "owner" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | -------------------------------------------------------------------------------------------------------- | -------- | --------------------------------- | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.UpsertWorkspaceAgentPortShareRequest](schemas.md#codersdkupsertworkspaceagentportsharerequest) | true | Upsert port sharing level request | + +### Example responses + +> 200 Response + +```json +{ + "agent_name": "string", + "port": 0, + "share_level": "owner", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceAgentPortShare](schemas.md#codersdkworkspaceagentportshare) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 42888fd1d1..364b943fc3 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2095,6 +2095,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `allow_path_app_sharing` | boolean | false | | | | `allow_path_app_site_owner_access` | boolean | false | | | +## codersdk.DeleteWorkspaceAgentPortShareRequest + +```json +{ + "agent_name": "string", + "port": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | ------- | -------- | ------------ | ----------- | +| `agent_name` | string | false | | | +| `port` | integer | false | | | + ## codersdk.DeploymentConfig ```json @@ -2901,9 +2917,10 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| --------- | -| `example` | +| Value | +| -------------- | +| `example` | +| `shared-ports` | ## codersdk.ExternalAuth @@ -4677,6 +4694,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -4713,6 +4731,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | | `icon` | string | false | | | | `id` | string | false | | | +| `max_port_share_level` | [codersdk.WorkspaceAgentPortShareLevel](#codersdkworkspaceagentportsharelevel) | false | | | | `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once autostop_requirement is matured | | `name` | string | false | | | | `organization_id` | string | false | | | @@ -5621,6 +5640,24 @@ If the schedule is empty, the user will be updated to use the default schedule.| | ------ | ------ | -------- | ------------ | ----------- | | `hash` | string | false | | | +## codersdk.UpsertWorkspaceAgentPortShareRequest + +```json +{ + "agent_name": "string", + "port": 0, + "share_level": "owner" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------- | ------------------------------------------------------------------------------ | -------- | ------------ | ----------- | +| `agent_name` | string | false | | | +| `port` | integer | false | | | +| `share_level` | [codersdk.WorkspaceAgentPortShareLevel](#codersdkworkspaceagentportsharelevel) | false | | | + ## codersdk.User ```json @@ -6535,6 +6572,63 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `script` | string | false | | | | `timeout` | integer | false | | | +## codersdk.WorkspaceAgentPortShare + +```json +{ + "agent_name": "string", + "port": 0, + "share_level": "owner", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------------------------------------------------------------------------------ | -------- | ------------ | ----------- | +| `agent_name` | string | false | | | +| `port` | integer | false | | | +| `share_level` | [codersdk.WorkspaceAgentPortShareLevel](#codersdkworkspaceagentportsharelevel) | false | | | +| `workspace_id` | string | false | | | + +## codersdk.WorkspaceAgentPortShareLevel + +```json +"owner" +``` + +### Properties + +#### Enumerated Values + +| Value | +| --------------- | +| `owner` | +| `authenticated` | +| `public` | + +## codersdk.WorkspaceAgentPortShares + +```json +{ + "shares": [ + { + "agent_name": "string", + "port": 0, + "share_level": "owner", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------- | ----------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `shares` | array of [codersdk.WorkspaceAgentPortShare](#codersdkworkspaceagentportshare) | false | | | + ## codersdk.WorkspaceAgentScript ```json diff --git a/docs/api/templates.md b/docs/api/templates.md index 7a08564172..4aadd04e32 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -60,6 +60,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -113,6 +114,7 @@ Status Code **200** | `» failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | | `» icon` | string | false | | | | `» id` | string(uuid) | false | | | +| `» max_port_share_level` | [codersdk.WorkspaceAgentPortShareLevel](schemas.md#codersdkworkspaceagentportsharelevel) | false | | | | `» max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once autostop_requirement is matured | | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | @@ -125,9 +127,12 @@ Status Code **200** #### Enumerated Values -| Property | Value | -| ------------- | ----------- | -| `provisioner` | `terraform` | +| Property | Value | +| ---------------------- | --------------- | +| `max_port_share_level` | `owner` | +| `max_port_share_level` | `authenticated` | +| `max_port_share_level` | `public` | +| `provisioner` | `terraform` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -222,6 +227,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -362,6 +368,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -678,6 +685,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -801,6 +809,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", diff --git a/docs/manifest.json b/docs/manifest.json index bb8223dfaf..12a5e3cfcc 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -550,6 +550,10 @@ "title": "Organizations", "path": "./api/organizations.md" }, + { + "title": "PortSharing", + "path": "./api/portsharing.md" + }, { "title": "Schemas", "path": "./api/schemas.md" diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index a0e7e3e8d8..821fcd3878 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -88,6 +88,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "time_til_dormant_autodelete": ActionTrack, "require_active_version": ActionTrack, "deprecated": ActionTrack, + "max_port_sharing_level": ActionTrack, "activity_bump": ActionTrack, }, &database.TemplateVersion{}: { diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 25bf7b971f..95611f671d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -15,6 +15,8 @@ import ( "time" "github.com/coder/coder/v2/coderd/appearance" + agplportsharing "github.com/coder/coder/v2/coderd/portsharing" + "github.com/coder/coder/v2/enterprise/coderd/portsharing" "golang.org/x/xerrors" "tailscale.com/tailcfg" @@ -533,6 +535,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureWorkspaceProxy: true, codersdk.FeatureUserRoleManagement: true, codersdk.FeatureAccessControl: true, + codersdk.FeatureControlSharedPorts: true, }) if err != nil { return err @@ -690,6 +693,14 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } + if initial, changed, enabled := featureChanged(codersdk.FeatureControlSharedPorts); shouldUpdate(initial, changed, enabled) { + var ps agplportsharing.PortSharer = agplportsharing.DefaultPortSharer + if enabled { + ps = portsharing.NewEnterprisePortSharer() + } + api.AGPL.PortSharer.Store(&ps) + } + // External token encryption is soft-enforced featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption] featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0 diff --git a/enterprise/coderd/portsharing/portsharing.go b/enterprise/coderd/portsharing/portsharing.go new file mode 100644 index 0000000000..94ff232927 --- /dev/null +++ b/enterprise/coderd/portsharing/portsharing.go @@ -0,0 +1,40 @@ +package portsharing + +import ( + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +type EnterprisePortSharer struct{} + +func NewEnterprisePortSharer() *EnterprisePortSharer { + return &EnterprisePortSharer{} +} + +func (EnterprisePortSharer) AuthorizedPortSharingLevel(template database.Template, level codersdk.WorkspaceAgentPortShareLevel) error { + max := codersdk.WorkspaceAgentPortShareLevel(template.MaxPortSharingLevel) + switch level { + case codersdk.WorkspaceAgentPortShareLevelPublic: + if max != codersdk.WorkspaceAgentPortShareLevelPublic { + return xerrors.Errorf("port sharing level not allowed. Max level is '%s'", max) + } + case codersdk.WorkspaceAgentPortShareLevelAuthenticated: + if max == codersdk.WorkspaceAgentPortShareLevelOwner { + return xerrors.Errorf("port sharing level not allowed. Max level is '%s'", max) + } + default: + return xerrors.New("port sharing level is invalid.") + } + + return nil +} + +func (EnterprisePortSharer) ValidateTemplateMaxPortSharingLevel(level codersdk.WorkspaceAgentPortShareLevel) error { + if !level.ValidMaxLevel() { + return xerrors.New("invalid max port sharing level, value must be 'authenticated' or 'public'.") + } + + return nil +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index ca70113744..260d0603fb 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -140,6 +140,43 @@ func TestTemplates(t *testing.T) { require.NoError(t, err) }) + t.Run("MaxPortShareLevel", func(t *testing.T) { + t.Parallel() + + owner, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureControlSharedPorts: 1, + }, + }, + }) + client, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // OK + var level codersdk.WorkspaceAgentPortShareLevel = codersdk.WorkspaceAgentPortShareLevelPublic + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + MaxPortShareLevel: &level, + }) + require.NoError(t, err) + assert.Equal(t, level, updated.MaxPortShareLevel) + + // Invalid level + level = "invalid" + _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + MaxPortShareLevel: &level, + }) + require.ErrorContains(t, err, "invalid max port sharing level") + }) + t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{ diff --git a/enterprise/coderd/workspaceportshare_test.go b/enterprise/coderd/workspaceportshare_test.go new file mode 100644 index 0000000000..04d2d83967 --- /dev/null +++ b/enterprise/coderd/workspaceportshare_test.go @@ -0,0 +1,63 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestWorkspacePortShare(t *testing.T) { + t.Parallel() + + dep := coderdtest.DeploymentValues(t) + dep.Experiments = append(dep.Experiments, string(codersdk.ExperimentSharedPorts)) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dep, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureControlSharedPorts: 1, + }, + }, + }) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + workspace, agent := setupWorkspaceAgent(t, client, codersdk.CreateFirstUserResponse{ + UserID: user.ID, + OrganizationID: owner.OrganizationID, + }, 0) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // try to update port share with template max port share level owner + _, err := client.UpsertWorkspaceAgentPortShare(ctx, workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agent.Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + }) + require.Error(t, err, "Port sharing level not allowed") + + // update the template max port share level to public + var level codersdk.WorkspaceAgentPortShareLevel = codersdk.WorkspaceAgentPortShareLevelPublic + client.UpdateTemplateMeta(ctx, workspace.TemplateID, codersdk.UpdateTemplateMeta{ + MaxPortShareLevel: &level, + }) + + // OK + ps, err := client.UpsertWorkspaceAgentPortShare(ctx, workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{ + AgentName: agent.Name, + Port: 8080, + ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + }) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceAgentPortShareLevelPublic, ps.ShareLevel) +} diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 549367d87e..b753226240 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -736,6 +736,10 @@ export const updateTemplateSettings = async ( await expect(page).toHaveURL(`/templates/${templateName}/settings`); for (const [key, value] of Object.entries(templateSettingValues)) { + // Skip max_port_share_level for now since the frontend is not yet able to handle it + if (key === "max_port_share_level") { + continue; + } const labelText = capitalize(key).replace("_", " "); await page.getByLabel(labelText, { exact: true }).fill(value); } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 878d6504f5..ac605e193f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -369,6 +369,12 @@ export interface DangerousConfig { readonly allow_all_cors: boolean; } +// From codersdk/workspaceagentportshare.go +export interface DeleteWorkspaceAgentPortShareRequest { + readonly agent_name: string; + readonly port: number; +} + // From codersdk/deployment.go export interface DeploymentConfig { readonly config?: DeploymentValues; @@ -1043,6 +1049,7 @@ export interface Template { readonly time_til_dormant_ms: number; readonly time_til_dormant_autodelete_ms: number; readonly require_active_version: boolean; + readonly max_port_share_level: WorkspaceAgentPortShareLevel; } // From codersdk/templates.go @@ -1302,6 +1309,7 @@ export interface UpdateTemplateMeta { readonly require_active_version: boolean; readonly deprecation_message?: string; readonly disable_everyone_group_access: boolean; + readonly max_port_share_level?: WorkspaceAgentPortShareLevel; } // From codersdk/users.go @@ -1362,6 +1370,13 @@ export interface UploadResponse { readonly hash: string; } +// From codersdk/workspaceagentportshare.go +export interface UpsertWorkspaceAgentPortShareRequest { + readonly agent_name: string; + readonly port: number; + readonly share_level: WorkspaceAgentPortShareLevel; +} + // From codersdk/users.go export interface User { readonly id: string; @@ -1611,6 +1626,19 @@ export interface WorkspaceAgentMetadataResult { readonly error: string; } +// From codersdk/workspaceagentportshare.go +export interface WorkspaceAgentPortShare { + readonly workspace_id: string; + readonly agent_name: string; + readonly port: number; + readonly share_level: WorkspaceAgentPortShareLevel; +} + +// From codersdk/workspaceagentportshare.go +export interface WorkspaceAgentPortShares { + readonly shares: WorkspaceAgentPortShare[]; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentScript { readonly log_source_id: string; @@ -1860,8 +1888,8 @@ export const Entitlements: Entitlement[] = [ ]; // From codersdk/deployment.go -export type Experiment = "example"; -export const Experiments: Experiment[] = ["example"]; +export type Experiment = "example" | "shared-ports"; +export const Experiments: Experiment[] = ["example", "shared-ports"]; // From codersdk/deployment.go export type FeatureName = @@ -1870,6 +1898,7 @@ export type FeatureName = | "appearance" | "audit_log" | "browser_only" + | "control_shared_ports" | "external_provisioner_daemons" | "external_token_encryption" | "high_availability" @@ -1887,6 +1916,7 @@ export const FeatureNames: FeatureName[] = [ "appearance", "audit_log", "browser_only", + "control_shared_ports", "external_provisioner_daemons", "external_token_encryption", "high_availability", @@ -2148,6 +2178,14 @@ export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [ "starting", ]; +// From codersdk/workspaceagentportshare.go +export type WorkspaceAgentPortShareLevel = "authenticated" | "owner" | "public"; +export const WorkspaceAgentPortShareLevels: WorkspaceAgentPortShareLevel[] = [ + "authenticated", + "owner", + "public", +]; + // From codersdk/workspaceagents.go export type WorkspaceAgentStartupScriptBehavior = "blocking" | "non-blocking"; export const WorkspaceAgentStartupScriptBehaviors: WorkspaceAgentStartupScriptBehavior[] = diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 439a68fef2..a025997c77 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -48,6 +48,7 @@ const validFormValues: FormValues = { update_workspace_dormant_at: false, require_active_version: false, disable_everyone_group_access: false, + max_port_share_level: "owner", }; const renderTemplateSettingsPage = async () => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f5bb958eab..ce4f9d836a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -489,6 +489,7 @@ export const MockTemplate: TypesGen.Template = { require_active_version: false, deprecated: false, deprecation_message: "", + max_port_share_level: "owner", }; export const MockTemplateVersionFiles: TemplateVersionFiles = {