diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index f495d71e1d..b2c5288276 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -4995,6 +4995,71 @@ const docTemplate = `{
}
}
},
+ "/workspaceproxies": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "Get workspace proxies",
+ "operationId": "get-workspace-proxies",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.WorkspaceProxy"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Templates"
+ ],
+ "summary": "Create workspace proxy",
+ "operationId": "create-workspace-proxy",
+ "parameters": [
+ {
+ "description": "Create workspace proxy request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateWorkspaceProxyRequest"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/codersdk.WorkspaceProxy"
+ }
+ }
+ }
+ }
+ },
"/workspaces": {
"get": {
"security": [
@@ -6706,6 +6771,26 @@ const docTemplate = `{
}
}
},
+ "codersdk.CreateWorkspaceProxyRequest": {
+ "type": "object",
+ "properties": {
+ "display_name": {
+ "type": "string"
+ },
+ "icon": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "wildcard_hostname": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.CreateWorkspaceRequest": {
"type": "object",
"required": [
@@ -7086,10 +7171,12 @@ const docTemplate = `{
"codersdk.Experiment": {
"type": "string",
"enum": [
- "template_editor"
+ "template_editor",
+ "moons"
],
"x-enum-varnames": [
- "ExperimentTemplateEditor"
+ "ExperimentTemplateEditor",
+ "ExperimentMoons"
]
},
"codersdk.Feature": {
@@ -9355,6 +9442,44 @@ const docTemplate = `{
}
}
},
+ "codersdk.WorkspaceProxy": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "deleted": {
+ "type": "boolean"
+ },
+ "icon": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organization_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "url": {
+ "description": "Full url including scheme of the proxy api url: https://us.example.com",
+ "type": "string"
+ },
+ "wildcard_hostname": {
+ "description": "WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com",
+ "type": "string"
+ }
+ }
+ },
"codersdk.WorkspaceQuota": {
"type": "object",
"properties": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 785db2ebbe..ea70396d68 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -4399,6 +4399,61 @@
}
}
},
+ "/workspaceproxies": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Enterprise"],
+ "summary": "Get workspace proxies",
+ "operationId": "get-workspace-proxies",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.WorkspaceProxy"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["Templates"],
+ "summary": "Create workspace proxy",
+ "operationId": "create-workspace-proxy",
+ "parameters": [
+ {
+ "description": "Create workspace proxy request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateWorkspaceProxyRequest"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/codersdk.WorkspaceProxy"
+ }
+ }
+ }
+ }
+ },
"/workspaces": {
"get": {
"security": [
@@ -5973,6 +6028,26 @@
}
}
},
+ "codersdk.CreateWorkspaceProxyRequest": {
+ "type": "object",
+ "properties": {
+ "display_name": {
+ "type": "string"
+ },
+ "icon": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "wildcard_hostname": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.CreateWorkspaceRequest": {
"type": "object",
"required": ["name", "template_id"],
@@ -6345,8 +6420,8 @@
},
"codersdk.Experiment": {
"type": "string",
- "enum": ["template_editor"],
- "x-enum-varnames": ["ExperimentTemplateEditor"]
+ "enum": ["template_editor", "moons"],
+ "x-enum-varnames": ["ExperimentTemplateEditor", "ExperimentMoons"]
},
"codersdk.Feature": {
"type": "object",
@@ -8447,6 +8522,44 @@
}
}
},
+ "codersdk.WorkspaceProxy": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "deleted": {
+ "type": "boolean"
+ },
+ "icon": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organization_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "url": {
+ "description": "Full url including scheme of the proxy api url: https://us.example.com",
+ "type": "string"
+ },
+ "wildcard_hostname": {
+ "description": "WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com",
+ "type": "string"
+ }
+ }
+ },
"codersdk.WorkspaceQuota": {
"type": "object",
"properties": {
diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index 1cc6702d1b..965c0e0851 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -16,7 +16,8 @@ type Auditable interface {
database.GitSSHKey |
database.WorkspaceBuild |
database.AuditableGroup |
- database.License
+ database.License |
+ database.WorkspaceProxy
}
// Map is a map of changed fields in an audited resource. It maps field names to
diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go
index 13699e28f7..ccb697795e 100644
--- a/coderd/database/dbauthz/querier.go
+++ b/coderd/database/dbauthz/querier.go
@@ -1685,6 +1685,34 @@ func (q *querier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceApp
return fetch(q.log, q.auth, q.db.GetWorkspaceByWorkspaceAppID)(ctx, workspaceAppID)
}
+func (q *querier) GetWorkspaceProxies(ctx context.Context) ([]database.WorkspaceProxy, error) {
+ return fetchWithPostFilter(q.auth, func(ctx context.Context, _ interface{}) ([]database.WorkspaceProxy, error) {
+ return q.db.GetWorkspaceProxies(ctx)
+ })(ctx, nil)
+}
+
+func (q *querier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (database.WorkspaceProxy, error) {
+ return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByID)(ctx, id)
+}
+
+func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+ return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg)
+}
+
+func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+ fetch := func(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+ return q.db.GetWorkspaceProxyByID(ctx, arg.ID)
+ }
+ return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceProxy)(ctx, arg)
+}
+
+func (q *querier) UpdateWorkspaceProxyDeleted(ctx context.Context, arg database.UpdateWorkspaceProxyDeletedParams) error {
+ fetch := func(ctx context.Context, arg database.UpdateWorkspaceProxyDeletedParams) (database.WorkspaceProxy, error) {
+ return q.db.GetWorkspaceProxyByID(ctx, arg.ID)
+ }
+ return deleteQ(q.log, q.auth, fetch, q.db.UpdateWorkspaceProxyDeleted)(ctx, arg)
+}
+
func authorizedTemplateVersionFromJob(ctx context.Context, q *querier, job database.ProvisionerJob) (database.TemplateVersion, error) {
switch job.Type {
case database.ProvisionerJobTypeTemplateVersionDryRun:
diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go
index 97f0bc7faa..2ebd0c5f44 100644
--- a/coderd/database/dbauthz/querier_test.go
+++ b/coderd/database/dbauthz/querier_test.go
@@ -438,6 +438,36 @@ func (s *MethodTestSuite) TestOrganization() {
}))
}
+func (s *MethodTestSuite) TestWorkspaceProxy() {
+ s.Run("InsertWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) {
+ check.Args(database.InsertWorkspaceProxyParams{
+ ID: uuid.New(),
+ }).Asserts(rbac.ResourceWorkspaceProxy, rbac.ActionCreate)
+ }))
+ s.Run("UpdateWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) {
+ p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
+ check.Args(database.UpdateWorkspaceProxyParams{
+ ID: p.ID,
+ }).Asserts(p, rbac.ActionUpdate)
+ }))
+ s.Run("GetWorkspaceProxyByID", s.Subtest(func(db database.Store, check *expects) {
+ p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
+ check.Args(p.ID).Asserts(p, rbac.ActionRead).Returns(p)
+ }))
+ s.Run("UpdateWorkspaceProxyDeleted", s.Subtest(func(db database.Store, check *expects) {
+ p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
+ check.Args(database.UpdateWorkspaceProxyDeletedParams{
+ ID: p.ID,
+ Deleted: true,
+ }).Asserts(p, rbac.ActionDelete)
+ }))
+ s.Run("GetWorkspaceProxies", s.Subtest(func(db database.Store, check *expects) {
+ p1 := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
+ p2 := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
+ check.Args().Asserts(p1, rbac.ActionRead, p2, rbac.ActionRead).Returns(slice.New(p1, p2))
+ }))
+}
+
func (s *MethodTestSuite) TestParameters() {
s.Run("Workspace/InsertParameterValue", s.Subtest(func(db database.Store, check *expects) {
w := dbgen.Workspace(s.T(), db, database.Workspace{})
diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go
index 6d406cfae8..4633e946ea 100644
--- a/coderd/database/dbfake/databasefake.go
+++ b/coderd/database/dbfake/databasefake.go
@@ -65,6 +65,7 @@ func New() database.Store {
workspaceApps: make([]database.WorkspaceApp, 0),
workspaces: make([]database.Workspace, 0),
licenses: make([]database.License, 0),
+ workspaceProxies: make([]database.WorkspaceProxy, 0),
locks: map[int64]struct{}{},
},
}
@@ -132,6 +133,7 @@ type data struct {
workspaceResourceMetadata []database.WorkspaceResourceMetadatum
workspaceResources []database.WorkspaceResource
workspaces []database.Workspace
+ workspaceProxies []database.WorkspaceProxy
// Locks is a map of lock names. Any keys within the map are currently
// locked.
@@ -4979,3 +4981,86 @@ func (q *fakeQuerier) UpdateWorkspaceAgentStartupLogOverflowByID(_ context.Conte
}
return sql.ErrNoRows
}
+
+func (q *fakeQuerier) GetWorkspaceProxies(_ context.Context) ([]database.WorkspaceProxy, error) {
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ cpy := make([]database.WorkspaceProxy, 0, len(q.workspaceProxies))
+
+ for _, p := range q.workspaceProxies {
+ if !p.Deleted {
+ cpy = append(cpy, p)
+ }
+ }
+ return cpy, nil
+}
+
+func (q *fakeQuerier) GetWorkspaceProxyByID(_ context.Context, id uuid.UUID) (database.WorkspaceProxy, error) {
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for _, proxy := range q.workspaceProxies {
+ if proxy.ID == id {
+ return proxy, nil
+ }
+ }
+ return database.WorkspaceProxy{}, sql.ErrNoRows
+}
+
+func (q *fakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for _, p := range q.workspaceProxies {
+ if !p.Deleted && p.Name == arg.Name {
+ return database.WorkspaceProxy{}, errDuplicateKey
+ }
+ }
+
+ p := database.WorkspaceProxy{
+ ID: arg.ID,
+ Name: arg.Name,
+ Icon: arg.Icon,
+ Url: arg.Url,
+ WildcardHostname: arg.WildcardHostname,
+ CreatedAt: arg.CreatedAt,
+ UpdatedAt: arg.UpdatedAt,
+ Deleted: false,
+ }
+ q.workspaceProxies = append(q.workspaceProxies, p)
+ return p, nil
+}
+
+func (q *fakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for i, p := range q.workspaceProxies {
+ if p.ID == arg.ID {
+ p.Name = arg.Name
+ p.Icon = arg.Icon
+ p.Url = arg.Url
+ p.WildcardHostname = arg.WildcardHostname
+ p.UpdatedAt = database.Now()
+ q.workspaceProxies[i] = p
+ return p, nil
+ }
+ }
+ return database.WorkspaceProxy{}, sql.ErrNoRows
+}
+
+func (q *fakeQuerier) UpdateWorkspaceProxyDeleted(_ context.Context, arg database.UpdateWorkspaceProxyDeletedParams) error {
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for i, p := range q.workspaceProxies {
+ if p.ID == arg.ID {
+ p.Deleted = arg.Deleted
+ p.UpdatedAt = database.Now()
+ q.workspaceProxies[i] = p
+ return nil
+ }
+ }
+ return sql.ErrNoRows
+}
diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go
index 084fad8cf3..3ba5205ab6 100644
--- a/coderd/database/dbgen/generator.go
+++ b/coderd/database/dbgen/generator.go
@@ -327,6 +327,21 @@ func WorkspaceResourceMetadatums(t testing.TB, db database.Store, seed database.
return meta
}
+func WorkspaceProxy(t testing.TB, db database.Store, orig database.WorkspaceProxy) database.WorkspaceProxy {
+ resource, err := db.InsertWorkspaceProxy(context.Background(), database.InsertWorkspaceProxyParams{
+ ID: takeFirst(orig.ID, uuid.New()),
+ Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)),
+ DisplayName: takeFirst(orig.DisplayName, namesgenerator.GetRandomName(1)),
+ Icon: takeFirst(orig.Icon, namesgenerator.GetRandomName(1)),
+ Url: takeFirst(orig.Url, fmt.Sprintf("https://%s.com", namesgenerator.GetRandomName(1))),
+ WildcardHostname: takeFirst(orig.WildcardHostname, fmt.Sprintf(".%s.com", namesgenerator.GetRandomName(1))),
+ CreatedAt: takeFirst(orig.CreatedAt, database.Now()),
+ UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()),
+ })
+ require.NoError(t, err, "insert app")
+ return resource
+}
+
func File(t testing.TB, db database.Store, orig database.File) database.File {
file, err := db.InsertFile(context.Background(), database.InsertFileParams{
ID: takeFirst(orig.ID, uuid.New()),
diff --git a/coderd/database/dbgen/generator_test.go b/coderd/database/dbgen/generator_test.go
index c09cc6df8a..25cc7646a5 100644
--- a/coderd/database/dbgen/generator_test.go
+++ b/coderd/database/dbgen/generator_test.go
@@ -75,6 +75,13 @@ func TestGenerator(t *testing.T) {
require.Equal(t, exp, must(db.GetWorkspaceResourceMetadataByResourceIDs(context.Background(), []uuid.UUID{exp[0].WorkspaceResourceID})))
})
+ t.Run("WorkspaceProxy", func(t *testing.T) {
+ t.Parallel()
+ db := dbfake.New()
+ exp := dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
+ require.Equal(t, exp, must(db.GetWorkspaceProxyByID(context.Background(), exp.ID)))
+ })
+
t.Run("Job", func(t *testing.T) {
t.Parallel()
db := dbfake.New()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 6115e8b506..71397f4cfb 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -635,6 +635,22 @@ CREATE TABLE workspace_builds (
max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL
);
+CREATE TABLE workspace_proxies (
+ id uuid NOT NULL,
+ name text NOT NULL,
+ display_name text NOT NULL,
+ icon text NOT NULL,
+ url text NOT NULL,
+ wildcard_hostname text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ deleted boolean NOT NULL
+);
+
+COMMENT ON COLUMN workspace_proxies.url IS 'Full url including scheme of the proxy api url: https://us.example.com';
+
+COMMENT ON COLUMN workspace_proxies.wildcard_hostname IS 'Hostname with the wildcard for subdomain based app hosting: *.us.example.com';
+
CREATE TABLE workspace_resource_metadata (
workspace_resource_id uuid NOT NULL,
key character varying(1024) NOT NULL,
@@ -804,6 +820,9 @@ ALTER TABLE ONLY workspace_builds
ALTER TABLE ONLY workspace_builds
ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
+ALTER TABLE ONLY workspace_proxies
+ ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY workspace_resource_metadata
ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key);
@@ -860,6 +879,8 @@ CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (au
CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id);
+CREATE UNIQUE INDEX workspace_proxies_name_idx ON workspace_proxies USING btree (name) WHERE (deleted = false);
+
CREATE INDEX workspace_resources_job_id_idx ON workspace_resources USING btree (job_id);
CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
diff --git a/coderd/database/migrations/000114_workspace_proxy.down.sql b/coderd/database/migrations/000114_workspace_proxy.down.sql
new file mode 100644
index 0000000000..8d428817f4
--- /dev/null
+++ b/coderd/database/migrations/000114_workspace_proxy.down.sql
@@ -0,0 +1,4 @@
+BEGIN;
+DROP TABLE workspace_proxies;
+
+COMMIT;
diff --git a/coderd/database/migrations/000114_workspace_proxy.up.sql b/coderd/database/migrations/000114_workspace_proxy.up.sql
new file mode 100644
index 0000000000..5030a5da79
--- /dev/null
+++ b/coderd/database/migrations/000114_workspace_proxy.up.sql
@@ -0,0 +1,23 @@
+BEGIN;
+CREATE TABLE workspace_proxies (
+ id uuid NOT NULL,
+ name text NOT NULL,
+ display_name text NOT NULL,
+ icon text NOT NULL,
+ url text NOT NULL,
+ wildcard_hostname text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ deleted boolean NOT NULL,
+
+ PRIMARY KEY (id)
+);
+
+COMMENT ON COLUMN workspace_proxies.url IS 'Full url including scheme of the proxy api url: https://us.example.com';
+COMMENT ON COLUMN workspace_proxies.wildcard_hostname IS 'Hostname with the wildcard for subdomain based app hosting: *.us.example.com';
+
+
+-- Enforces no active proxies have the same name.
+CREATE UNIQUE INDEX ON workspace_proxies (name) WHERE deleted = FALSE;
+
+COMMIT;
diff --git a/coderd/database/migrations/testdata/fixtures/000114_workspace_proxy.up.sql b/coderd/database/migrations/testdata/fixtures/000114_workspace_proxy.up.sql
new file mode 100644
index 0000000000..83fac5c49f
--- /dev/null
+++ b/coderd/database/migrations/testdata/fixtures/000114_workspace_proxy.up.sql
@@ -0,0 +1,14 @@
+INSERT INTO workspace_proxies
+ (id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted)
+VALUES
+ (
+ 'cf8ede8c-ff47-441f-a738-d92e4e34a657',
+ 'us',
+ 'United States',
+ '/emojis/us.png',
+ 'https://us.coder.com',
+ '*.us.coder.com',
+ '2023-03-30 12:00:00.000+02',
+ '2023-03-30 12:00:00.000+02',
+ false
+ );
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index 376a4c1247..1c20a0cced 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -181,6 +181,11 @@ func (p ProvisionerDaemon) RBACObject() rbac.Object {
return rbac.ResourceProvisionerDaemon.WithID(p.ID)
}
+func (w WorkspaceProxy) RBACObject() rbac.Object {
+ return rbac.ResourceWorkspaceProxy.
+ WithID(w.ID)
+}
+
func (f File) RBACObject() rbac.Object {
return rbac.ResourceFile.
WithID(f.ID).
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 8ccafb0030..a0b11b2d3b 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -1663,6 +1663,20 @@ type WorkspaceBuildParameter struct {
Value string `db:"value" json:"value"`
}
+type WorkspaceProxy struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ Name string `db:"name" json:"name"`
+ DisplayName string `db:"display_name" json:"display_name"`
+ Icon string `db:"icon" json:"icon"`
+ // Full url including scheme of the proxy api url: https://us.example.com
+ Url string `db:"url" json:"url"`
+ // Hostname with the wildcard for subdomain based app hosting: *.us.example.com
+ WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Deleted bool `db:"deleted" json:"deleted"`
+}
+
type WorkspaceResource struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 2cfc2475d7..3ea7443a46 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -146,6 +146,8 @@ type sqlcQuerier interface {
GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error)
GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error)
GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error)
+ GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
+ GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error)
GetWorkspaceResourceMetadataCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResourceMetadatum, error)
@@ -193,6 +195,7 @@ type sqlcQuerier interface {
InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error)
InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) (WorkspaceBuild, error)
InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error
+ InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error)
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error)
@@ -241,6 +244,8 @@ type sqlcQuerier interface {
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) (WorkspaceBuild, error)
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
+ UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
+ UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error
UpsertLastUpdateCheck(ctx context.Context, value string) error
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 4d25b30947..58cd4c23c1 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -2803,6 +2803,198 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a
return err
}
+const getWorkspaceProxies = `-- name: GetWorkspaceProxies :many
+SELECT
+ id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
+FROM
+ workspace_proxies
+WHERE
+ deleted = false
+`
+
+func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) {
+ rows, err := q.db.QueryContext(ctx, getWorkspaceProxies)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []WorkspaceProxy
+ for rows.Next() {
+ var i WorkspaceProxy
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.DisplayName,
+ &i.Icon,
+ &i.Url,
+ &i.WildcardHostname,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Deleted,
+ ); 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 getWorkspaceProxyByID = `-- name: GetWorkspaceProxyByID :one
+SELECT
+ id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
+FROM
+ workspace_proxies
+WHERE
+ id = $1
+LIMIT
+ 1
+`
+
+func (q *sqlQuerier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error) {
+ row := q.db.QueryRowContext(ctx, getWorkspaceProxyByID, id)
+ var i WorkspaceProxy
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.DisplayName,
+ &i.Icon,
+ &i.Url,
+ &i.WildcardHostname,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Deleted,
+ )
+ return i, err
+}
+
+const insertWorkspaceProxy = `-- name: InsertWorkspaceProxy :one
+INSERT INTO
+ workspace_proxies (
+ id,
+ name,
+ display_name,
+ icon,
+ url,
+ wildcard_hostname,
+ created_at,
+ updated_at,
+ deleted
+ )
+VALUES
+ ($1, $2, $3, $4, $5, $6, $7, $8, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
+`
+
+type InsertWorkspaceProxyParams struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ Name string `db:"name" json:"name"`
+ DisplayName string `db:"display_name" json:"display_name"`
+ Icon string `db:"icon" json:"icon"`
+ Url string `db:"url" json:"url"`
+ WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) {
+ row := q.db.QueryRowContext(ctx, insertWorkspaceProxy,
+ arg.ID,
+ arg.Name,
+ arg.DisplayName,
+ arg.Icon,
+ arg.Url,
+ arg.WildcardHostname,
+ arg.CreatedAt,
+ arg.UpdatedAt,
+ )
+ var i WorkspaceProxy
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.DisplayName,
+ &i.Icon,
+ &i.Url,
+ &i.WildcardHostname,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Deleted,
+ )
+ return i, err
+}
+
+const updateWorkspaceProxy = `-- name: UpdateWorkspaceProxy :one
+UPDATE
+ workspace_proxies
+SET
+ name = $1,
+ display_name = $2,
+ url = $3,
+ wildcard_hostname = $4,
+ icon = $5,
+ updated_at = Now()
+WHERE
+ id = $6
+RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
+`
+
+type UpdateWorkspaceProxyParams struct {
+ Name string `db:"name" json:"name"`
+ DisplayName string `db:"display_name" json:"display_name"`
+ Url string `db:"url" json:"url"`
+ WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
+ Icon string `db:"icon" json:"icon"`
+ ID uuid.UUID `db:"id" json:"id"`
+}
+
+func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) {
+ row := q.db.QueryRowContext(ctx, updateWorkspaceProxy,
+ arg.Name,
+ arg.DisplayName,
+ arg.Url,
+ arg.WildcardHostname,
+ arg.Icon,
+ arg.ID,
+ )
+ var i WorkspaceProxy
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.DisplayName,
+ &i.Icon,
+ &i.Url,
+ &i.WildcardHostname,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Deleted,
+ )
+ return i, err
+}
+
+const updateWorkspaceProxyDeleted = `-- name: UpdateWorkspaceProxyDeleted :exec
+UPDATE
+ workspace_proxies
+SET
+ updated_at = Now(),
+ deleted = $1
+WHERE
+ id = $2
+`
+
+type UpdateWorkspaceProxyDeletedParams struct {
+ Deleted bool `db:"deleted" json:"deleted"`
+ ID uuid.UUID `db:"id" json:"id"`
+}
+
+func (q *sqlQuerier) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error {
+ _, err := q.db.ExecContext(ctx, updateWorkspaceProxyDeleted, arg.Deleted, arg.ID)
+ return err
+}
+
const getQuotaAllowanceForUser = `-- name: GetQuotaAllowanceForUser :one
SELECT
coalesce(SUM(quota_allowance), 0)::BIGINT
diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql
new file mode 100644
index 0000000000..73d02ce20d
--- /dev/null
+++ b/coderd/database/queries/proxies.sql
@@ -0,0 +1,57 @@
+-- name: InsertWorkspaceProxy :one
+INSERT INTO
+ workspace_proxies (
+ id,
+ name,
+ display_name,
+ icon,
+ url,
+ wildcard_hostname,
+ created_at,
+ updated_at,
+ deleted
+ )
+VALUES
+ ($1, $2, $3, $4, $5, $6, $7, $8, false) RETURNING *;
+
+-- name: UpdateWorkspaceProxy :one
+UPDATE
+ workspace_proxies
+SET
+ name = @name,
+ display_name = @display_name,
+ url = @url,
+ wildcard_hostname = @wildcard_hostname,
+ icon = @icon,
+ updated_at = Now()
+WHERE
+ id = @id
+RETURNING *;
+
+
+-- name: UpdateWorkspaceProxyDeleted :exec
+UPDATE
+ workspace_proxies
+SET
+ updated_at = Now(),
+ deleted = @deleted
+WHERE
+ id = @id;
+
+-- name: GetWorkspaceProxyByID :one
+SELECT
+ *
+FROM
+ workspace_proxies
+WHERE
+ id = $1
+LIMIT
+ 1;
+
+-- name: GetWorkspaceProxies :many
+SELECT
+ *
+FROM
+ workspace_proxies
+WHERE
+ deleted = false;
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index f09f6ebdae..f0e564c81a 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -31,5 +31,6 @@ const (
UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false);
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
+ UniqueWorkspaceProxiesNameIndex UniqueConstraint = "workspace_proxies_name_idx" // CREATE UNIQUE INDEX workspace_proxies_name_idx ON workspace_proxies USING btree (name) WHERE (deleted = false);
UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
)
diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go
index d83d2fa36c..ead5fad556 100644
--- a/coderd/rbac/object.go
+++ b/coderd/rbac/object.go
@@ -22,6 +22,14 @@ var (
Type: "workspace",
}
+ // ResourceWorkspaceProxy CRUD. Org
+ // create/delete = make or delete proxies
+ // read = read proxy urls
+ // update = edit workspace proxy fields
+ ResourceWorkspaceProxy = Object{
+ Type: "workspace_proxy",
+ }
+
// ResourceWorkspaceExecution CRUD. Org + User owner
// create = workspace remote execution
// read = ?
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 3247ad194d..5ae146ae4f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -45,6 +45,7 @@ const (
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
FeatureAppearance FeatureName = "appearance"
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
+ FeatureWorkspaceProxy FeatureName = "workspace_proxy"
)
// FeatureNames must be kept in-sync with the Feature enum above.
@@ -59,6 +60,7 @@ var FeatureNames = []FeatureName{
FeatureExternalProvisionerDaemons,
FeatureAppearance,
FeatureAdvancedTemplateScheduling,
+ FeatureWorkspaceProxy,
}
// Humanize returns the feature name in a human-readable format.
@@ -1559,6 +1561,10 @@ const (
// for all users.
ExperimentTemplateEditor Experiment = "template_editor"
+ // ExperimentMoons enabled the workspace proxy endpoints and CRUD. This
+ // feature is not yet complete in functionality.
+ ExperimentMoons Experiment = "moons"
+
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
diff --git a/codersdk/workspaceproxy.go b/codersdk/workspaceproxy.go
new file mode 100644
index 0000000000..d9bc442774
--- /dev/null
+++ b/codersdk/workspaceproxy.go
@@ -0,0 +1,69 @@
+package codersdk
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "golang.org/x/xerrors"
+
+ "github.com/google/uuid"
+)
+
+type CreateWorkspaceProxyRequest struct {
+ Name string `json:"name"`
+ DisplayName string `json:"display_name"`
+ Icon string `json:"icon"`
+ URL string `json:"url"`
+ WildcardHostname string `json:"wildcard_hostname"`
+}
+
+type WorkspaceProxy struct {
+ ID uuid.UUID `db:"id" json:"id" format:"uuid"`
+ OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"`
+ Name string `db:"name" json:"name"`
+ Icon string `db:"icon" json:"icon"`
+ // Full url including scheme of the proxy api url: https://us.example.com
+ URL string `db:"url" json:"url"`
+ // WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com
+ WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
+ CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"`
+ Deleted bool `db:"deleted" json:"deleted"`
+}
+
+func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (WorkspaceProxy, error) {
+ res, err := c.Request(ctx, http.MethodPost,
+ "/api/v2/workspaceproxies",
+ req,
+ )
+ if err != nil {
+ return WorkspaceProxy{}, xerrors.Errorf("make request: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusCreated {
+ return WorkspaceProxy{}, ReadBodyAsError(res)
+ }
+ var resp WorkspaceProxy
+ return resp, json.NewDecoder(res.Body).Decode(&resp)
+}
+
+func (c *Client) WorkspaceProxiesByOrganization(ctx context.Context) ([]WorkspaceProxy, error) {
+ res, err := c.Request(ctx, http.MethodGet,
+ "/api/v2/workspaceproxies",
+ nil,
+ )
+ if err != nil {
+ return nil, xerrors.Errorf("make request: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return nil, ReadBodyAsError(res)
+ }
+
+ var proxies []WorkspaceProxy
+ return proxies, json.NewDecoder(res.Body).Decode(&proxies)
+}
diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md
index 16ad4c597f..43ad3d2ef0 100644
--- a/docs/admin/audit-logs.md
+++ b/docs/admin/audit-logs.md
@@ -20,6 +20,7 @@ We track the following resources:
| User
create, write, delete |
| Field | Tracked |
|---|
| avatar_url | false |
| created_at | false |
| deleted | true |
| email | true |
| hashed_password | true |
| id | true |
| last_seen_at | false |
| login_type | false |
| rbac_roles | true |
| status | true |
| updated_at | false |
| username | true |
|
| Workspace
create, write, delete | | Field | Tracked |
|---|
| autostart_schedule | true |
| created_at | false |
| deleted | false |
| id | true |
| last_used_at | false |
| name | true |
| organization_id | false |
| owner_id | true |
| template_id | true |
| ttl | true |
| updated_at | false |
|
| WorkspaceBuild
start, stop | | Field | Tracked |
|---|
| build_number | false |
| created_at | false |
| daily_cost | false |
| deadline | false |
| id | false |
| initiator_id | false |
| job_id | false |
| max_deadline | false |
| provisioner_state | false |
| reason | false |
| template_version_id | true |
| transition | false |
| updated_at | false |
| workspace_id | false |
|
+| WorkspaceProxy
| | Field | Tracked |
|---|
| created_at | true |
| deleted | true |
| display_name | true |
| icon | true |
| id | true |
| name | true |
| updated_at | true |
| url | true |
| wildcard_hostname | true |
|
diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md
index 13dc4bfc9c..486bc213d1 100644
--- a/docs/api/enterprise.md
+++ b/docs/api/enterprise.md
@@ -1181,3 +1181,61 @@ curl -X GET http://coder-server:8080/api/v2/workspace-quota/{user} \
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceQuota](schemas.md#codersdkworkspacequota) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Get workspace proxies
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/workspaceproxies \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /workspaceproxies`
+
+### Example responses
+
+> 200 Response
+
+```json
+[
+ {
+ "created_at": "2019-08-24T14:15:22Z",
+ "deleted": true,
+ "icon": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "name": "string",
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "updated_at": "2019-08-24T14:15:22Z",
+ "url": "string",
+ "wildcard_hostname": "string"
+ }
+]
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------- |
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
+
+Response Schema
+
+Status Code **200**
+
+| Name | Type | Required | Restrictions | Description |
+| --------------------- | ----------------- | -------- | ------------ | -------------------------------------------------------------------------------------- |
+| `[array item]` | array | false | | |
+| `» created_at` | string(date-time) | false | | |
+| `» deleted` | boolean | false | | |
+| `» icon` | string | false | | |
+| `» id` | string(uuid) | false | | |
+| `» name` | string | false | | |
+| `» organization_id` | string(uuid) | false | | |
+| `» updated_at` | string(date-time) | false | | |
+| `» url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com |
+| `» wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 8c934d4e06..e2e47feac0 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -1543,6 +1543,28 @@ CreateParameterRequest is a structure used to create a new parameter value for a
| `transition` | `stop` |
| `transition` | `delete` |
+## codersdk.CreateWorkspaceProxyRequest
+
+```json
+{
+ "display_name": "string",
+ "icon": "string",
+ "name": "string",
+ "url": "string",
+ "wildcard_hostname": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+| ------------------- | ------ | -------- | ------------ | ----------- |
+| `display_name` | string | false | | |
+| `icon` | string | false | | |
+| `name` | string | false | | |
+| `url` | string | false | | |
+| `wildcard_hostname` | string | false | | |
+
## codersdk.CreateWorkspaceRequest
```json
@@ -2430,6 +2452,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
| Value |
| ----------------- |
| `template_editor` |
+| `moons` |
## codersdk.Feature
@@ -5085,6 +5108,36 @@ Parameter represents a set value for the scope.
| `stopped` | integer | false | | |
| `tx_bytes` | integer | false | | |
+## codersdk.WorkspaceProxy
+
+```json
+{
+ "created_at": "2019-08-24T14:15:22Z",
+ "deleted": true,
+ "icon": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "name": "string",
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "updated_at": "2019-08-24T14:15:22Z",
+ "url": "string",
+ "wildcard_hostname": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+| ------------------- | ------- | -------- | ------------ | -------------------------------------------------------------------------------------- |
+| `created_at` | string | false | | |
+| `deleted` | boolean | false | | |
+| `icon` | string | false | | |
+| `id` | string | false | | |
+| `name` | string | false | | |
+| `organization_id` | string | false | | |
+| `updated_at` | string | false | | |
+| `url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com |
+| `wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com |
+
## codersdk.WorkspaceQuota
```json
diff --git a/docs/api/templates.md b/docs/api/templates.md
index d967e33449..7d23e6387c 100644
--- a/docs/api/templates.md
+++ b/docs/api/templates.md
@@ -2543,3 +2543,61 @@ Status Code **200**
| `type` | `bool` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Create workspace proxy
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`POST /workspaceproxies`
+
+> Body parameter
+
+```json
+{
+ "display_name": "string",
+ "icon": "string",
+ "name": "string",
+ "url": "string",
+ "wildcard_hostname": "string"
+}
+```
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
+| `body` | body | [codersdk.CreateWorkspaceProxyRequest](schemas.md#codersdkcreateworkspaceproxyrequest) | true | Create workspace proxy request |
+
+### Example responses
+
+> 201 Response
+
+```json
+{
+ "created_at": "2019-08-24T14:15:22Z",
+ "deleted": true,
+ "icon": "string",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "name": "string",
+ "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
+ "updated_at": "2019-08-24T14:15:22Z",
+ "url": "string",
+ "wildcard_hostname": "string"
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------ |
+| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index ca86ff6cd7..a9c8d667da 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -162,6 +162,17 @@ var auditableResourcesTypes = map[any]map[string]Action{
"exp": ActionTrack,
"uuid": ActionTrack,
},
+ &database.WorkspaceProxy{}: {
+ "id": ActionTrack,
+ "name": ActionTrack,
+ "display_name": ActionTrack,
+ "icon": ActionTrack,
+ "url": ActionTrack,
+ "wildcard_hostname": ActionTrack,
+ "created_at": ActionTrack,
+ "updated_at": ActionTrack,
+ "deleted": ActionTrack,
+ },
}
// auditMap converts a map of struct pointers to a map of struct names as
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index aedcd49b1a..62d22866c1 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -81,6 +81,22 @@ func New(ctx context.Context, options *Options) (*API, error) {
r.Get("/", api.licenses)
r.Delete("/{id}", api.deleteLicense)
})
+ r.Route("/workspaceproxies", func(r chi.Router) {
+ r.Use(
+ apiKeyMiddleware,
+ api.moonsEnabledMW,
+ )
+ r.Post("/", api.postWorkspaceProxy)
+ r.Get("/", api.workspaceProxies)
+ // TODO: Add specific workspace proxy endpoints.
+ //r.Route("/{proxyName}", func(r chi.Router) {
+ // r.Use(
+ // httpmw.ExtractWorkspaceProxyByNameParam(api.Database),
+ // )
+ //
+ // r.Get("/", api.workspaceProxyByName)
+ //})
+ })
r.Route("/organizations/{organization}/groups", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
@@ -254,6 +270,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureTemplateRBAC: api.RBAC,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureAdvancedTemplateScheduling: true,
+ codersdk.FeatureWorkspaceProxy: true,
})
if err != nil {
return err
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index b4b5d96831..0dad01620b 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -53,6 +53,7 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureExternalProvisionerDaemons: 1,
codersdk.FeatureAdvancedTemplateScheduling: 1,
+ codersdk.FeatureWorkspaceProxy: 1,
},
})
res, err := client.Entitlements(context.Background())
diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go
index 42dee6a2f0..cbd4547154 100644
--- a/enterprise/coderd/templates.go
+++ b/enterprise/coderd/templates.go
@@ -279,3 +279,24 @@ func (api *API) templateRBACEnabledMW(next http.Handler) http.Handler {
next.ServeHTTP(rw, r)
})
}
+
+func (api *API) moonsEnabledMW(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ // The experiment must be enabled.
+ if !api.AGPL.Experiments.Enabled(codersdk.ExperimentMoons) {
+ httpapi.RouteNotFound(rw)
+ return
+ }
+
+ // Entitlement must be enabled.
+ api.entitlementsMu.RLock()
+ proxy := api.entitlements.Features[codersdk.FeatureWorkspaceProxy].Enabled
+ api.entitlementsMu.RUnlock()
+ if !proxy {
+ httpapi.RouteNotFound(rw)
+ return
+ }
+
+ next.ServeHTTP(rw, r)
+ })
+}
diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go
new file mode 100644
index 0000000000..e23f94e10a
--- /dev/null
+++ b/enterprise/coderd/workspaceproxy.go
@@ -0,0 +1,139 @@
+package coderd
+
+import (
+ "database/sql"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/coderd/audit"
+ "github.com/coder/coder/coderd/database"
+ "github.com/coder/coder/coderd/httpapi"
+ "github.com/coder/coder/codersdk"
+ "github.com/google/uuid"
+)
+
+// @Summary Create workspace proxy
+// @ID create-workspace-proxy
+// @Security CoderSessionToken
+// @Accept json
+// @Produce json
+// @Tags Templates
+// @Param request body codersdk.CreateWorkspaceProxyRequest true "Create workspace proxy request"
+// @Success 201 {object} codersdk.WorkspaceProxy
+// @Router /workspaceproxies [post]
+func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
+ var (
+ ctx = r.Context()
+ auditor = api.AGPL.Auditor.Load()
+ aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{
+ Audit: *auditor,
+ Log: api.Logger,
+ Request: r,
+ Action: database.AuditActionCreate,
+ })
+ )
+ defer commitAudit()
+
+ var req codersdk.CreateWorkspaceProxyRequest
+ if !httpapi.Read(ctx, rw, r, &req) {
+ return
+ }
+
+ if err := validateProxyURL(req.URL); err != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "URL is invalid.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ if _, err := httpapi.CompileHostnamePattern(req.WildcardHostname); err != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Wildcard URL is invalid.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ proxy, err := api.Database.InsertWorkspaceProxy(ctx, database.InsertWorkspaceProxyParams{
+ ID: uuid.New(),
+ Name: req.Name,
+ DisplayName: req.DisplayName,
+ Icon: req.Icon,
+ Url: req.URL,
+ WildcardHostname: req.WildcardHostname,
+ CreatedAt: database.Now(),
+ UpdatedAt: database.Now(),
+ })
+ if database.IsUniqueViolation(err) {
+ httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
+ Message: fmt.Sprintf("Workspace proxy with name %q already exists.", req.Name),
+ })
+ return
+ }
+ if err != nil {
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+
+ aReq.New = proxy
+ httpapi.Write(ctx, rw, http.StatusCreated, convertProxy(proxy))
+}
+
+// nolint:revive
+func validateProxyURL(u string) error {
+ p, err := url.Parse(u)
+ if err != nil {
+ return err
+ }
+ if p.Scheme != "http" && p.Scheme != "https" {
+ return xerrors.New("scheme must be http or https")
+ }
+ if !(p.Path == "/" || p.Path == "") {
+ return xerrors.New("path must be empty or /")
+ }
+ return nil
+}
+
+// @Summary Get workspace proxies
+// @ID get-workspace-proxies
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Enterprise
+// @Success 200 {array} codersdk.WorkspaceProxy
+// @Router /workspaceproxies [get]
+func (api *API) workspaceProxies(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ proxies, err := api.Database.GetWorkspaceProxies(ctx)
+ if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+
+ httpapi.Write(ctx, rw, http.StatusOK, convertProxies(proxies))
+}
+
+func convertProxies(p []database.WorkspaceProxy) []codersdk.WorkspaceProxy {
+ resp := make([]codersdk.WorkspaceProxy, 0, len(p))
+ for _, proxy := range p {
+ resp = append(resp, convertProxy(proxy))
+ }
+ return resp
+}
+
+func convertProxy(p database.WorkspaceProxy) codersdk.WorkspaceProxy {
+ return codersdk.WorkspaceProxy{
+ ID: p.ID,
+ Name: p.Name,
+ Icon: p.Icon,
+ URL: p.Url,
+ WildcardHostname: p.WildcardHostname,
+ CreatedAt: p.CreatedAt,
+ UpdatedAt: p.UpdatedAt,
+ Deleted: p.Deleted,
+ }
+}
diff --git a/enterprise/coderd/workspaceproxy_internal_test.go b/enterprise/coderd/workspaceproxy_internal_test.go
new file mode 100644
index 0000000000..9654e0ecc3
--- /dev/null
+++ b/enterprise/coderd/workspaceproxy_internal_test.go
@@ -0,0 +1,62 @@
+package coderd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_validateProxyURL(t *testing.T) {
+ t.Parallel()
+
+ testcases := []struct {
+ Name string
+ URL string
+ ExpectedError bool
+ }{
+ {
+ Name: "Empty",
+ URL: "",
+ ExpectedError: true,
+ },
+ {
+ Name: "EmptyWild",
+ URL: "",
+ ExpectedError: true,
+ },
+ {
+ Name: "URL",
+ URL: "https://example.com",
+ ExpectedError: false,
+ },
+ {
+ Name: "NoScheme",
+ URL: "example.com",
+ ExpectedError: true,
+ },
+ {
+ Name: "BadScheme",
+ URL: "ssh://example.com",
+ ExpectedError: true,
+ },
+ {
+ Name: "IncludePaths",
+ URL: "https://example.com/test",
+ ExpectedError: true,
+ },
+ }
+
+ for _, tt := range testcases {
+ tt := tt
+ t.Run(tt.Name, func(t *testing.T) {
+ t.Parallel()
+
+ err := validateProxyURL(tt.URL)
+ if tt.ExpectedError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/enterprise/coderd/workspaceproxy_test.go b/enterprise/coderd/workspaceproxy_test.go
new file mode 100644
index 0000000000..3cffc4f44d
--- /dev/null
+++ b/enterprise/coderd/workspaceproxy_test.go
@@ -0,0 +1,52 @@
+package coderd_test
+
+import (
+ "testing"
+
+ "github.com/moby/moby/pkg/namesgenerator"
+
+ "github.com/coder/coder/coderd/coderdtest"
+ "github.com/coder/coder/codersdk"
+ "github.com/coder/coder/enterprise/coderd/coderdenttest"
+ "github.com/coder/coder/enterprise/coderd/license"
+ "github.com/coder/coder/testutil"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWorkspaceProxyCRUD(t *testing.T) {
+ t.Parallel()
+
+ t.Run("create", func(t *testing.T) {
+ t.Parallel()
+
+ dv := coderdtest.DeploymentValues(t)
+ dv.Experiments = []string{
+ string(codersdk.ExperimentMoons),
+ "*",
+ }
+ client := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ DeploymentValues: dv,
+ },
+ })
+ _ = coderdtest.CreateFirstUser(t, client)
+ _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspaceProxy: 1,
+ },
+ })
+ ctx := testutil.Context(t, testutil.WaitLong)
+ proxy, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
+ Name: namesgenerator.GetRandomName(1),
+ Icon: "/emojis/flag.png",
+ URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
+ WildcardHostname: "*.sub.example.com",
+ })
+ require.NoError(t, err)
+
+ proxies, err := client.WorkspaceProxiesByOrganization(ctx)
+ require.NoError(t, err)
+ require.Len(t, proxies, 1)
+ require.Equal(t, proxy, proxies[0])
+ })
+}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index a546718b9b..a656f5f6f2 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -253,6 +253,15 @@ export interface CreateWorkspaceBuildRequest {
readonly log_level?: ProvisionerLogLevel
}
+// From codersdk/workspaceproxy.go
+export interface CreateWorkspaceProxyRequest {
+ readonly name: string
+ readonly display_name: string
+ readonly icon: string
+ readonly url: string
+ readonly wildcard_hostname: string
+}
+
// From codersdk/organizations.go
export interface CreateWorkspaceRequest {
readonly template_id: string
@@ -1201,6 +1210,19 @@ export interface WorkspaceOptions {
readonly include_deleted?: boolean
}
+// From codersdk/workspaceproxy.go
+export interface WorkspaceProxy {
+ readonly id: string
+ readonly organization_id: string
+ readonly name: string
+ readonly icon: string
+ readonly url: string
+ readonly wildcard_hostname: string
+ readonly created_at: string
+ readonly updated_at: string
+ readonly deleted: boolean
+}
+
// From codersdk/workspaces.go
export interface WorkspaceQuota {
readonly credits_consumed: number
@@ -1280,8 +1302,8 @@ export const Entitlements: Entitlement[] = [
]
// From codersdk/deployment.go
-export type Experiment = "template_editor"
-export const Experiments: Experiment[] = ["template_editor"]
+export type Experiment = "moons" | "template_editor"
+export const Experiments: Experiment[] = ["moons", "template_editor"]
// From codersdk/deployment.go
export type FeatureName =
@@ -1295,6 +1317,7 @@ export type FeatureName =
| "scim"
| "template_rbac"
| "user_limit"
+ | "workspace_proxy"
export const FeatureNames: FeatureName[] = [
"advanced_template_scheduling",
"appearance",
@@ -1306,6 +1329,7 @@ export const FeatureNames: FeatureName[] = [
"scim",
"template_rbac",
"user_limit",
+ "workspace_proxy",
]
// From codersdk/workspaceagents.go