feat: add port-sharing backend (#11939)

This commit is contained in:
Garrett Delfosse 2024-02-13 09:31:20 -05:00 committed by GitHub
parent c939416702
commit 3ab3a62bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1947 additions and 59 deletions

195
coderd/apidoc/docs.go generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,20 +8,20 @@ We track the following resources:
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
| <b>Resource<b> | |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_max_ttl</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>theme_preference</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
| <b>Resource<b> | |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_max_ttl</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>theme_preference</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/audit-logs.md'. -->

90
docs/api/portsharing.md generated Normal file
View File

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

100
docs/api/schemas.md generated
View File

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

15
docs/api/templates.md generated
View File

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

View File

@ -550,6 +550,10 @@
"title": "Organizations",
"path": "./api/organizations.md"
},
{
"title": "PortSharing",
"path": "./api/portsharing.md"
},
{
"title": "Schemas",
"path": "./api/schemas.md"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[] =

View File

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

View File

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