chore: Allow editing proxy fields via api. (#7435)

* chore: Add ability to update workspace proxy fields
This commit is contained in:
Steven Masley 2023-05-09 13:46:50 -05:00 committed by GitHub
parent fc1bc374cb
commit b5ad628460
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 886 additions and 68 deletions

108
coderd/apidoc/docs.go generated
View File

@ -5178,6 +5178,39 @@ const docTemplate = `{
}
},
"/workspaceproxies/{workspaceproxy}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get workspace proxy",
"operationId": "get-workspace-proxy",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Proxy ID or name",
"name": "workspaceproxy",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceProxy"
}
}
}
},
"delete": {
"security": [
{
@ -5210,6 +5243,51 @@ const docTemplate = `{
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Update workspace proxy",
"operationId": "update-workspace-proxy",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Proxy ID or name",
"name": "workspaceproxy",
"in": "path",
"required": true
},
{
"description": "Update workspace proxy request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PatchWorkspaceProxy"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceProxy"
}
}
}
}
},
"/workspaces": {
@ -8074,6 +8152,33 @@ const docTemplate = `{
}
}
},
"codersdk.PatchWorkspaceProxy": {
"type": "object",
"required": [
"display_name",
"icon",
"id",
"name"
],
"properties": {
"display_name": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"regenerate_token": {
"type": "boolean"
}
}
},
"codersdk.PprofConfig": {
"type": "object",
"properties": {
@ -9856,6 +9961,9 @@ const docTemplate = `{
"deleted": {
"type": "boolean"
},
"display_name": {
"type": "string"
},
"icon": {
"type": "string"
},

View File

@ -4554,6 +4554,35 @@
}
},
"/workspaceproxies/{workspaceproxy}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get workspace proxy",
"operationId": "get-workspace-proxy",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Proxy ID or name",
"name": "workspaceproxy",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceProxy"
}
}
}
},
"delete": {
"security": [
{
@ -4582,6 +4611,45 @@
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Update workspace proxy",
"operationId": "update-workspace-proxy",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Proxy ID or name",
"name": "workspaceproxy",
"in": "path",
"required": true
},
{
"description": "Update workspace proxy request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PatchWorkspaceProxy"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceProxy"
}
}
}
}
},
"/workspaces": {
@ -7216,6 +7284,28 @@
}
}
},
"codersdk.PatchWorkspaceProxy": {
"type": "object",
"required": ["display_name", "icon", "id", "name"],
"properties": {
"display_name": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"regenerate_token": {
"type": "boolean"
}
}
},
"codersdk.PprofConfig": {
"type": "object",
"properties": {
@ -8893,6 +8983,9 @@
"deleted": {
"type": "boolean"
},
"display_name": {
"type": "string"
},
"icon": {
"type": "string"
},

View File

@ -1705,6 +1705,13 @@ func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertW
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) RegisterWorkspaceProxy(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
fetch := func(ctx context.Context, arg database.RegisterWorkspaceProxyParams) (database.WorkspaceProxy, error) {
return q.db.GetWorkspaceProxyByID(ctx, arg.ID)

View File

@ -5233,6 +5233,31 @@ func (q *fakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg
return database.WorkspaceProxy{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for _, p := range q.workspaceProxies {
if p.Name == arg.Name && p.ID != arg.ID {
return database.WorkspaceProxy{}, errDuplicateKey
}
}
for i, p := range q.workspaceProxies {
if p.ID == arg.ID {
p.Name = arg.Name
p.DisplayName = arg.DisplayName
p.Icon = arg.Icon
if len(p.TokenHashedSecret) > 0 {
p.TokenHashedSecret = arg.TokenHashedSecret
}
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()

View File

@ -255,6 +255,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
// This allows editing the properties of a workspace proxy.
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

View File

@ -3077,6 +3077,60 @@ func (q *sqlQuerier) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWor
return i, err
}
const updateWorkspaceProxy = `-- name: UpdateWorkspaceProxy :one
UPDATE
workspace_proxies
SET
-- These values should always be provided.
name = $1,
display_name = $2,
icon = $3,
-- Only update the token if a new one is provided.
-- So this is an optional field.
token_hashed_secret = CASE
WHEN length($4 :: bytea) > 0 THEN $4 :: bytea
ELSE workspace_proxies.token_hashed_secret
END,
-- Always update this timestamp.
updated_at = Now()
WHERE
id = $5
RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
`
type UpdateWorkspaceProxyParams struct {
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
ID uuid.UUID `db:"id" json:"id"`
}
// This allows editing the properties of a workspace proxy.
func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, updateWorkspaceProxy,
arg.Name,
arg.DisplayName,
arg.Icon,
arg.TokenHashedSecret,
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,
&i.TokenHashedSecret,
)
return i, err
}
const updateWorkspaceProxyDeleted = `-- name: UpdateWorkspaceProxyDeleted :exec
UPDATE
workspace_proxies

View File

@ -36,6 +36,28 @@ SET
WHERE
id = @id;
-- name: UpdateWorkspaceProxy :one
-- This allows editing the properties of a workspace proxy.
UPDATE
workspace_proxies
SET
-- These values should always be provided.
name = @name,
display_name = @display_name,
icon = @icon,
-- Only update the token if a new one is provided.
-- So this is an optional field.
token_hashed_secret = CASE
WHEN length(@token_hashed_secret :: bytea) > 0 THEN @token_hashed_secret :: bytea
ELSE workspace_proxies.token_hashed_secret
END,
-- Always update this timestamp.
updated_at = Now()
WHERE
id = @id
RETURNING *
;
-- name: GetWorkspaceProxyByID :one
SELECT
*
@ -57,6 +79,14 @@ WHERE
LIMIT
1;
-- name: GetWorkspaceProxies :many
SELECT
*
FROM
workspace_proxies
WHERE
deleted = false;
-- Finds a workspace proxy that has an access URL or app hostname that matches
-- the provided hostname. This is to check if a hostname matches any workspace
-- proxy.
@ -94,11 +124,3 @@ WHERE
)
LIMIT
1;
-- name: GetWorkspaceProxies :many
SELECT
*
FROM
workspace_proxies
WHERE
deleted = false;

View File

@ -46,9 +46,10 @@ type ProxyHealthReport struct {
}
type WorkspaceProxy struct {
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
Name string `json:"name" table:"name,default_sort"`
Icon string `json:"icon" table:"icon"`
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
Name string `json:"name" table:"name,default_sort"`
DisplayName string `json:"display_name" table:"display_name"`
Icon string `json:"icon" table:"icon"`
// Full url including scheme of the proxy api url: https://us.example.com
URL string `json:"url" table:"url"`
// WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com
@ -69,26 +70,26 @@ type CreateWorkspaceProxyRequest struct {
Icon string `json:"icon"`
}
type CreateWorkspaceProxyResponse struct {
type UpdateWorkspaceProxyResponse struct {
Proxy WorkspaceProxy `json:"proxy" table:"proxy,recursive"`
// The recursive table sort is not working very well.
ProxyToken string `json:"proxy_token" table:"proxy token,default_sort"`
}
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (CreateWorkspaceProxyResponse, error) {
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (UpdateWorkspaceProxyResponse, error) {
res, err := c.Request(ctx, http.MethodPost,
"/api/v2/workspaceproxies",
req,
)
if err != nil {
return CreateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err)
return UpdateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return CreateWorkspaceProxyResponse{}, ReadBodyAsError(res)
return UpdateWorkspaceProxyResponse{}, ReadBodyAsError(res)
}
var resp CreateWorkspaceProxyResponse
var resp UpdateWorkspaceProxyResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
@ -110,6 +111,31 @@ func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
return proxies, json.NewDecoder(res.Body).Decode(&proxies)
}
type PatchWorkspaceProxy struct {
ID uuid.UUID `json:"id" format:"uuid" validate:"required"`
Name string `json:"name" validate:"required"`
DisplayName string `json:"display_name" validate:"required"`
Icon string `json:"icon" validate:"required"`
RegenerateToken bool `json:"regenerate_token"`
}
func (c *Client) PatchWorkspaceProxy(ctx context.Context, req PatchWorkspaceProxy) (UpdateWorkspaceProxyResponse, error) {
res, err := c.Request(ctx, http.MethodPatch,
fmt.Sprintf("/api/v2/workspaceproxies/%s", req.ID.String()),
req,
)
if err != nil {
return UpdateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UpdateWorkspaceProxyResponse{}, ReadBodyAsError(res)
}
var resp UpdateWorkspaceProxyResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) DeleteWorkspaceProxyByName(ctx context.Context, name string) error {
res, err := c.Request(ctx, http.MethodDelete,
fmt.Sprintf("/api/v2/workspaceproxies/%s", name),
@ -131,6 +157,28 @@ func (c *Client) DeleteWorkspaceProxyByID(ctx context.Context, id uuid.UUID) err
return c.DeleteWorkspaceProxyByName(ctx, id.String())
}
func (c *Client) WorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/workspaceproxies/%s", name),
nil,
)
if err != nil {
return WorkspaceProxy{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceProxy{}, ReadBodyAsError(res)
}
var resp WorkspaceProxy
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) WorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error) {
return c.WorkspaceProxyByName(ctx, id.String())
}
type RegionsResponse struct {
Regions []Region `json:"regions"`
}

View File

@ -1182,6 +1182,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies \
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
@ -1215,6 +1216,7 @@ Status Code **200**
| `[array item]` | array | false | | |
| `» created_at` | string(date-time) | false | | |
| `» deleted` | boolean | false | | |
| `» display_name` | string | false | | |
| `» icon` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
@ -1277,6 +1279,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
@ -1302,6 +1305,59 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace proxy
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /workspaceproxies/{workspaceproxy}`
### Parameters
| Name | In | Type | Required | Description |
| ---------------- | ---- | ------------ | -------- | ---------------- |
| `workspaceproxy` | path | string(uuid) | true | Proxy ID or name |
### Example responses
> 200 Response
```json
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": {
"checked_at": "2019-08-24T14:15:22Z",
"report": {
"errors": ["string"],
"warnings": ["string"]
},
"status": "reachable"
},
"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 | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Delete workspace proxy
### Code samples
@ -1345,3 +1401,70 @@ curl -X DELETE http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy}
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Update workspace proxy
### Code samples
```shell
# Example request using curl
curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`PATCH /workspaceproxies/{workspaceproxy}`
> Body parameter
```json
{
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"regenerate_token": true
}
```
### Parameters
| Name | In | Type | Required | Description |
| ---------------- | ---- | ---------------------------------------------------------------------- | -------- | ------------------------------ |
| `workspaceproxy` | path | string(uuid) | true | Proxy ID or name |
| `body` | body | [codersdk.PatchWorkspaceProxy](schemas.md#codersdkpatchworkspaceproxy) | true | Update workspace proxy request |
### Example responses
> 200 Response
```json
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": {
"checked_at": "2019-08-24T14:15:22Z",
"report": {
"errors": ["string"],
"warnings": ["string"]
},
"status": "reachable"
},
"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 | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@ -3212,6 +3212,28 @@ Parameter represents a set value for the scope.
| ------ | ------ | -------- | ------------ | ----------- |
| `name` | string | false | | |
## codersdk.PatchWorkspaceProxy
```json
{
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"regenerate_token": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------- | -------- | ------------ | ----------- |
| `display_name` | string | true | | |
| `icon` | string | true | | |
| `id` | string | true | | |
| `name` | string | true | | |
| `regenerate_token` | boolean | false | | |
## codersdk.PprofConfig
```json
@ -5329,6 +5351,7 @@ Parameter represents a set value for the scope.
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"display_name": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
@ -5352,6 +5375,7 @@ Parameter represents a set value for the scope.
| ------------------- | -------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `created_at` | string | false | | |
| `deleted` | boolean | false | | |
| `display_name` | string | false | | |
| `icon` | string | false | | |
| `id` | string | false | | |
| `name` | string | false | | |

View File

@ -1,6 +1,7 @@
package cli
import (
"context"
"fmt"
"strings"
@ -26,12 +27,158 @@ func (r *RootCmd) workspaceProxy() *clibase.Cmd {
r.createProxy(),
r.deleteProxy(),
r.listProxies(),
r.patchProxy(),
r.regenerateProxyToken(),
},
}
return cmd
}
func (r *RootCmd) regenerateProxyToken() *clibase.Cmd {
formatter := newUpdateProxyResponseFormatter()
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "regenerate-token <name|id>",
Short: "Regenerate a workspace proxy authentication token. " +
"This will invalidate the existing authentication token.",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
// This is cheeky, but you can also use a uuid string in
// 'DeleteWorkspaceProxyByName' and it will work.
proxy, err := client.WorkspaceProxyByName(ctx, inv.Args[0])
if err != nil {
return xerrors.Errorf("fetch workspace proxy %q: %w", inv.Args[0], err)
}
// Only regenerate the token
updated, err := client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{
ID: proxy.ID,
Name: proxy.Name,
DisplayName: proxy.DisplayName,
Icon: proxy.Icon,
RegenerateToken: true,
})
if err != nil {
return xerrors.Errorf("update workspace proxy %q: %w", inv.Args[0], err)
}
output, err := formatter.Format(ctx, updated)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
func (r *RootCmd) patchProxy() *clibase.Cmd {
var (
proxyName string
displayName string
proxyIcon string
formatter = cliui.NewOutputFormatter(
// Text formatter should be human readable.
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
response, ok := data.(codersdk.WorkspaceProxy)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return fmt.Sprintf("Workspace Proxy %q updated successfully.", response.Name), nil
}),
cliui.JSONFormat(),
// Table formatter expects a slice, make a slice of one.
cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.WorkspaceProxy{}, []string{"proxy name", "proxy url"}),
func(data any) (any, error) {
response, ok := data.(codersdk.WorkspaceProxy)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return []codersdk.WorkspaceProxy{response}, nil
}),
)
)
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "edit <name|id>",
Short: "Edit a workspace proxy",
Middleware: clibase.Chain(
clibase.RequireNArgs(1),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
if proxyIcon == "" && displayName == "" && proxyName == "" {
return xerrors.Errorf("specify at least one field to update")
}
// This is cheeky, but you can also use a uuid string in
// 'DeleteWorkspaceProxyByName' and it will work.
proxy, err := client.WorkspaceProxyByName(ctx, inv.Args[0])
if err != nil {
return xerrors.Errorf("fetch workspace proxy %q: %w", inv.Args[0], err)
}
// Use the existing values if the user didn't specify them.
if proxyName == "" {
proxyName = proxy.Name
}
if displayName == "" {
displayName = proxy.DisplayName
}
if proxyIcon == "" {
proxyIcon = proxy.Icon
}
updated, err := client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{
ID: proxy.ID,
Name: proxyName,
DisplayName: displayName,
Icon: proxyIcon,
})
if err != nil {
return xerrors.Errorf("update workspace proxy %q: %w", inv.Args[0], err)
}
output, err := formatter.Format(ctx, updated.Proxy)
if err != nil {
return xerrors.Errorf("format response: %w", err)
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
}
formatter.AttachOptions(&cmd.Options)
cmd.Options.Add(
clibase.Option{
Flag: "name",
Description: "(Optional) Name of the proxy. This is used to identify the proxy.",
Value: clibase.StringOf(&proxyName),
},
clibase.Option{
Flag: "display-name",
Description: "(Optional) Display of the proxy. A more human friendly name to be displayed.",
Value: clibase.StringOf(&displayName),
},
clibase.Option{
Flag: "icon",
Description: "(Optional) Display icon of the proxy.",
Value: clibase.StringOf(&proxyIcon),
},
)
return cmd
}
func (r *RootCmd) deleteProxy() *clibase.Cmd {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
@ -61,28 +208,7 @@ func (r *RootCmd) createProxy() *clibase.Cmd {
proxyName string
displayName string
proxyIcon string
onlyToken bool
formatter = cliui.NewOutputFormatter(
// Text formatter should be human readable.
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
response, ok := data.(codersdk.CreateWorkspaceProxyResponse)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return fmt.Sprintf("Workspace Proxy %q created successfully. Save this token, it will not be shown again."+
"\nToken: %s", response.Proxy.Name, response.ProxyToken), nil
}),
cliui.JSONFormat(),
// Table formatter expects a slice, make a slice of one.
cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.CreateWorkspaceProxyResponse{}, []string{"proxy name", "proxy url", "proxy token"}),
func(data any) (any, error) {
response, ok := data.(codersdk.CreateWorkspaceProxyResponse)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return []codersdk.CreateWorkspaceProxyResponse{response}, nil
}),
)
formatter = newUpdateProxyResponseFormatter()
)
client := new(codersdk.Client)
@ -108,16 +234,10 @@ func (r *RootCmd) createProxy() *clibase.Cmd {
return xerrors.Errorf("create workspace proxy: %w", err)
}
var output string
if onlyToken {
output = resp.ProxyToken
} else {
output, err = formatter.Format(ctx, resp)
if err != nil {
return err
}
output, err := formatter.Format(ctx, resp)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
@ -140,11 +260,6 @@ func (r *RootCmd) createProxy() *clibase.Cmd {
Description: "Display icon of the proxy.",
Value: clibase.StringOf(&proxyIcon),
},
clibase.Option{
Flag: "only-token",
Description: "Only print the token. This is useful for scripting.",
Value: clibase.BoolOf(&onlyToken),
},
)
return cmd
}
@ -205,3 +320,56 @@ func (r *RootCmd) listProxies() *clibase.Cmd {
formatter.AttachOptions(&cmd.Options)
return cmd
}
// updateProxyResponseFormatter is used for both create and regenerate proxy commands.
type updateProxyResponseFormatter struct {
onlyToken bool
formatter *cliui.OutputFormatter
}
func (f *updateProxyResponseFormatter) Format(ctx context.Context, data codersdk.UpdateWorkspaceProxyResponse) (string, error) {
if f.onlyToken {
return data.ProxyToken, nil
}
return f.formatter.Format(ctx, data)
}
func (f *updateProxyResponseFormatter) AttachOptions(opts *clibase.OptionSet) {
opts.Add(
clibase.Option{
Flag: "only-token",
Description: "Only print the token. This is useful for scripting.",
Value: clibase.BoolOf(&f.onlyToken),
},
)
f.formatter.AttachOptions(opts)
}
func newUpdateProxyResponseFormatter() *updateProxyResponseFormatter {
up := &updateProxyResponseFormatter{
onlyToken: false,
formatter: cliui.NewOutputFormatter(
// Text formatter should be human readable.
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
response, ok := data.(codersdk.UpdateWorkspaceProxyResponse)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return fmt.Sprintf("Workspace Proxy %q created successfully. Save this token, it will not be shown again."+
"\nToken: %s", response.Proxy.Name, response.ProxyToken), nil
}),
cliui.JSONFormat(),
// Table formatter expects a slice, make a slice of one.
cliui.ChangeFormatterData(cliui.TableFormat([]codersdk.UpdateWorkspaceProxyResponse{}, []string{"proxy name", "proxy url", "proxy token"}),
func(data any) (any, error) {
response, ok := data.(codersdk.UpdateWorkspaceProxyResponse)
if !ok {
return nil, xerrors.Errorf("unexpected type %T", data)
}
return []codersdk.UpdateWorkspaceProxyResponse{response}, nil
}),
),
}
return up
}

View File

@ -120,6 +120,8 @@ func New(ctx context.Context, options *Options) (*API, error) {
httpmw.ExtractWorkspaceProxyParam(api.Database),
)
r.Get("/", api.workspaceProxy)
r.Patch("/", api.patchWorkspaceProxy)
r.Delete("/", api.deleteWorkspaceProxy)
})
})

View File

@ -90,6 +90,79 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
})
}
// @Summary Update workspace proxy
// @ID update-workspace-proxy
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Enterprise
// @Param workspaceproxy path string true "Proxy ID or name" format(uuid)
// @Param request body codersdk.PatchWorkspaceProxy true "Update workspace proxy request"
// @Success 200 {object} codersdk.WorkspaceProxy
// @Router /workspaceproxies/{workspaceproxy} [patch]
func (api *API) patchWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
proxy = httpmw.WorkspaceProxyParam(r)
auditor = api.AGPL.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
aReq.Old = proxy
defer commitAudit()
var req codersdk.PatchWorkspaceProxy
if !httpapi.Read(ctx, rw, r, &req) {
return
}
var hashedSecret []byte
var fullToken string
if req.RegenerateToken {
var err error
fullToken, hashedSecret, err = generateWorkspaceProxyToken(proxy.ID)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
updatedProxy, err := api.Database.UpdateWorkspaceProxy(ctx, database.UpdateWorkspaceProxyParams{
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
ID: proxy.ID,
// If hashedSecret is nil or empty, this will not update the secret.
TokenHashedSecret: hashedSecret,
})
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
aReq.New = updatedProxy
status, ok := api.ProxyHealth.HealthStatus()[updatedProxy.ID]
if !ok {
// The proxy should have some status, but just in case.
status.Status = proxyhealth.Unknown
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateWorkspaceProxyResponse{
Proxy: convertProxy(updatedProxy, status),
ProxyToken: fullToken,
})
// Update the proxy cache.
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
}
// @Summary Delete workspace proxy
// @ID delete-workspace-proxy
// @Security CoderSessionToken
@ -107,7 +180,7 @@ func (api *API) deleteWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
Action: database.AuditActionDelete,
})
)
aReq.Old = proxy
@ -135,6 +208,23 @@ func (api *API) deleteWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
}
// @Summary Get workspace proxy
// @ID get-workspace-proxy
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param workspaceproxy path string true "Proxy ID or name" format(uuid)
// @Success 200 {object} codersdk.WorkspaceProxy
// @Router /workspaceproxies/{workspaceproxy} [get]
func (api *API) workspaceProxy(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
proxy = httpmw.WorkspaceProxyParam(r)
)
httpapi.Write(ctx, rw, http.StatusOK, convertProxy(proxy, api.ProxyHealth.HealthStatus()[proxy.ID]))
}
// @Summary Create workspace proxy
// @ID create-workspace-proxy
// @Security CoderSessionToken
@ -177,13 +267,11 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
}
id := uuid.New()
secret, err := cryptorand.HexString(64)
fullToken, hashedSecret, err := generateWorkspaceProxyToken(id)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
hashedSecret := sha256.Sum256([]byte(secret))
fullToken := fmt.Sprintf("%s:%s", id, secret)
proxy, err := api.Database.InsertWorkspaceProxy(ctx, database.InsertWorkspaceProxyParams{
ID: id,
@ -206,7 +294,7 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
}
aReq.New = proxy
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateWorkspaceProxyResponse{
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UpdateWorkspaceProxyResponse{
Proxy: convertProxy(proxy, proxyhealth.ProxyStatus{
Proxy: proxy,
CheckedAt: time.Now(),
@ -325,7 +413,20 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
var (
ctx = r.Context()
proxy = httpmw.WorkspaceProxy(r)
// TODO: This audit log does not work because it has no user id
// associated with it. The audit log commitAudit() function ignores
// the audit log if there is no user id. We should find a solution
// to make sure this event is tracked.
// auditor = api.AGPL.Auditor.Load()
// aReq, commitAudit = audit.InitRequest[database.WorkspaceProxy](rw, &audit.RequestParams{
// Audit: *auditor,
// Log: api.Logger,
// Request: r,
// Action: database.AuditActionWrite,
//})
)
// aReq.Old = proxy
// defer commitAudit()
var req wsproxysdk.RegisterWorkspaceProxyRequest
if !httpapi.Read(ctx, rw, r, &req) {
@ -364,6 +465,7 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
return
}
// aReq.New = updatedProxy
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.RegisterWorkspaceProxyResponse{
AppSecurityKey: api.AppSecurityKey.String(),
})
@ -467,6 +569,16 @@ func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Reque
})
}
func generateWorkspaceProxyToken(id uuid.UUID) (token string, hashed []byte, err error) {
secret, err := cryptorand.HexString(64)
if err != nil {
return "", nil, xerrors.Errorf("generate token: %w", err)
}
hashedSecret := sha256.Sum256([]byte(secret))
fullToken := fmt.Sprintf("%s:%s", id, secret)
return fullToken, hashedSecret[:], nil
}
func convertProxies(p []database.WorkspaceProxy, statuses map[uuid.UUID]proxyhealth.ProxyStatus) []codersdk.WorkspaceProxy {
resp := make([]codersdk.WorkspaceProxy, 0, len(p))
for _, proxy := range p {
@ -479,6 +591,7 @@ func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) cod
return codersdk.WorkspaceProxy{
ID: p.ID,
Name: p.Name,
DisplayName: p.DisplayName,
Icon: p.Icon,
URL: p.Url,
WildcardHostname: p.WildcardHostname,

View File

@ -177,7 +177,7 @@ func TestRegions(t *testing.T) {
func TestWorkspaceProxyCRUD(t *testing.T) {
t.Parallel()
t.Run("create", func(t *testing.T) {
t.Run("CreateAndUpdate", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
@ -203,14 +203,33 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
})
require.NoError(t, err)
proxies, err := client.WorkspaceProxies(ctx)
found, err := client.WorkspaceProxyByID(ctx, proxyRes.Proxy.ID)
require.NoError(t, err)
require.Len(t, proxies, 1)
require.Equal(t, proxyRes.Proxy.ID, proxies[0].ID)
// This will be different, so set it to the same
found.Status = proxyRes.Proxy.Status
require.Equal(t, proxyRes.Proxy, found, "expected proxy")
require.NotEmpty(t, proxyRes.ProxyToken)
// Update the proxy
expName := namesgenerator.GetRandomName(1)
expDisplayName := namesgenerator.GetRandomName(1)
expIcon := namesgenerator.GetRandomName(1)
_, err = client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{
ID: proxyRes.Proxy.ID,
Name: expName,
DisplayName: expDisplayName,
Icon: expIcon,
})
require.NoError(t, err, "expected no error updating proxy")
found, err = client.WorkspaceProxyByID(ctx, proxyRes.Proxy.ID)
require.NoError(t, err)
require.Equal(t, expName, found.Name, "name")
require.Equal(t, expDisplayName, found.DisplayName, "display name")
require.Equal(t, expIcon, found.Icon, "icon")
})
t.Run("delete", func(t *testing.T) {
t.Run("Delete", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)

View File

@ -264,12 +264,6 @@ export interface CreateWorkspaceProxyRequest {
readonly icon: string
}
// From codersdk/workspaceproxy.go
export interface CreateWorkspaceProxyResponse {
readonly proxy: WorkspaceProxy
readonly proxy_token: string
}
// From codersdk/organizations.go
export interface CreateWorkspaceRequest {
readonly template_id: string
@ -632,6 +626,15 @@ export interface PatchTemplateVersionRequest {
readonly name: string
}
// From codersdk/workspaceproxy.go
export interface PatchWorkspaceProxy {
readonly id: string
readonly name: string
readonly display_name: string
readonly icon: string
readonly regenerate_token: boolean
}
// From codersdk/deployment.go
export interface PprofConfig {
readonly enable: boolean
@ -1034,6 +1037,12 @@ export interface UpdateWorkspaceAutostartRequest {
readonly schedule?: string
}
// From codersdk/workspaceproxy.go
export interface UpdateWorkspaceProxyResponse {
readonly proxy: WorkspaceProxy
readonly proxy_token: string
}
// From codersdk/workspaces.go
export interface UpdateWorkspaceRequest {
readonly name?: string
@ -1264,6 +1273,7 @@ export interface WorkspaceOptions {
export interface WorkspaceProxy {
readonly id: string
readonly name: string
readonly display_name: string
readonly icon: string
readonly url: string
readonly wildcard_hostname: string