feat: Implement experiment gated CRUD for workspace proxies (#6928)

* feat: Implement basic moon crud
* chore: Implement enterprise endpoints for moons
This commit is contained in:
Steven Masley 2023-04-04 15:07:29 -05:00 committed by GitHub
parent 385a4262e2
commit b4afbe7720
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1327 additions and 7 deletions

129
coderd/apidoc/docs.go generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
BEGIN;
DROP TABLE workspace_proxies;
COMMIT;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ We track the following resources:
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/audit-logs.md'. -->

View File

@ -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) |
<h3 id="get-workspace-proxies-responseschema">Response Schema</h3>
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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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