From b4afbe7720a1b527222968c8e2eb4b9f6ef3b768 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 4 Apr 2023 15:07:29 -0500 Subject: [PATCH] feat: Implement experiment gated CRUD for workspace proxies (#6928) * feat: Implement basic moon crud * chore: Implement enterprise endpoints for moons --- coderd/apidoc/docs.go | 129 +++++++++++- coderd/apidoc/swagger.json | 117 ++++++++++- coderd/audit/diff.go | 3 +- coderd/database/dbauthz/querier.go | 28 +++ coderd/database/dbauthz/querier_test.go | 30 +++ coderd/database/dbfake/databasefake.go | 85 ++++++++ coderd/database/dbgen/generator.go | 15 ++ coderd/database/dbgen/generator_test.go | 7 + coderd/database/dump.sql | 21 ++ .../000114_workspace_proxy.down.sql | 4 + .../migrations/000114_workspace_proxy.up.sql | 23 +++ .../fixtures/000114_workspace_proxy.up.sql | 14 ++ coderd/database/modelmethods.go | 5 + coderd/database/models.go | 14 ++ coderd/database/querier.go | 5 + coderd/database/queries.sql.go | 192 ++++++++++++++++++ coderd/database/queries/proxies.sql | 57 ++++++ coderd/database/unique_constraint.go | 1 + coderd/rbac/object.go | 8 + codersdk/deployment.go | 6 + codersdk/workspaceproxy.go | 69 +++++++ docs/admin/audit-logs.md | 1 + docs/api/enterprise.md | 58 ++++++ docs/api/schemas.md | 53 +++++ docs/api/templates.md | 58 ++++++ enterprise/audit/table.go | 11 + enterprise/coderd/coderd.go | 17 ++ enterprise/coderd/coderd_test.go | 1 + enterprise/coderd/templates.go | 21 ++ enterprise/coderd/workspaceproxy.go | 139 +++++++++++++ .../coderd/workspaceproxy_internal_test.go | 62 ++++++ enterprise/coderd/workspaceproxy_test.go | 52 +++++ site/src/api/typesGenerated.ts | 28 ++- 33 files changed, 1327 insertions(+), 7 deletions(-) create mode 100644 coderd/database/migrations/000114_workspace_proxy.down.sql create mode 100644 coderd/database/migrations/000114_workspace_proxy.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000114_workspace_proxy.up.sql create mode 100644 coderd/database/queries/proxies.sql create mode 100644 codersdk/workspaceproxy.go create mode 100644 enterprise/coderd/workspaceproxy.go create mode 100644 enterprise/coderd/workspaceproxy_internal_test.go create mode 100644 enterprise/coderd/workspaceproxy_test.go 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 |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedtrue
display_nametrue
icontrue
idtrue
nametrue
updated_attrue
urltrue
wildcard_hostnametrue
| 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