Skip to content

Commit f9b1656

Browse files
committed
feat: add regions endpoint for proxies feature
1 parent 8fc8559 commit f9b1656

File tree

7 files changed

+387
-6
lines changed

7 files changed

+387
-6
lines changed

coderd/coderd.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,11 @@ func New(options *Options) *API {
461461
r.Post("/csp/reports", api.logReportCSPViolations)
462462

463463
r.Get("/buildinfo", buildInfo(api.AccessURL))
464+
// /regions is overridden in the enterprise version
465+
r.Group(func(r chi.Router) {
466+
r.Use(apiKeyMiddleware)
467+
r.Get("/regions", api.regions)
468+
})
464469
r.Route("/deployment", func(r chi.Router) {
465470
r.Use(apiKeyMiddleware)
466471
r.Get("/config", api.deploymentValues)

coderd/workspaceproxies.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package coderd
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"net/http"
7+
8+
"github.com/google/uuid"
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/coder/coderd/database/dbauthz"
12+
"github.com/coder/coder/coderd/httpapi"
13+
"github.com/coder/coder/codersdk"
14+
)
15+
16+
func (api *API) PrimaryRegion(ctx context.Context) (codersdk.Region, error) {
17+
deploymentIDStr, err := api.Database.GetDeploymentID(ctx)
18+
if xerrors.Is(err, sql.ErrNoRows) {
19+
// This shouldn't happen but it's pretty easy to avoid this causing
20+
// issues by falling back to a nil UUID.
21+
deploymentIDStr = uuid.Nil.String()
22+
} else if err != nil {
23+
return codersdk.Region{}, xerrors.Errorf("get deployment ID: %w", err)
24+
}
25+
deploymentID, err := uuid.Parse(deploymentIDStr)
26+
if err != nil {
27+
// This also shouldn't happen but we fallback to nil UUID.
28+
deploymentID = uuid.Nil
29+
}
30+
31+
return codersdk.Region{
32+
ID: deploymentID,
33+
// TODO: provide some way to customize these fields for the primary
34+
// region
35+
Name: "primary",
36+
DisplayName: "Default",
37+
IconURL: "/emojis/1f60e.png", // face with sunglasses
38+
Healthy: true,
39+
PathAppURL: api.AccessURL.String(),
40+
WildcardHostname: api.AppHostname,
41+
}, nil
42+
}
43+
44+
// @Summary Get site-wide regions for workspace connections
45+
// @ID get-site-wide-regions-for-workspace-connections
46+
// @Security CoderSessionToken
47+
// @Produce json
48+
// @Tags WorkspaceProxies
49+
// @Success 200 {object} codersdk.RegionsResponse
50+
// @Router /regions [get]
51+
func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
52+
ctx := r.Context()
53+
//nolint:gocritic // this route intentionally requests resources that users
54+
// cannot usually access in order to give them a full list of available
55+
// regions.
56+
ctx = dbauthz.AsSystemRestricted(ctx)
57+
58+
region, err := api.PrimaryRegion(ctx)
59+
if err != nil {
60+
httpapi.InternalServerError(rw, err)
61+
return
62+
}
63+
64+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse{
65+
Regions: []codersdk.Region{region},
66+
})
67+
}

coderd/workspaceproxies_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package coderd_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/uuid"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/coderd/coderdtest"
10+
"github.com/coder/coder/coderd/database/dbtestutil"
11+
"github.com/coder/coder/codersdk"
12+
"github.com/coder/coder/testutil"
13+
)
14+
15+
func TestRegions(t *testing.T) {
16+
t.Parallel()
17+
18+
t.Run("OK", func(t *testing.T) {
19+
t.Parallel()
20+
const appHostname = "*.apps.coder.test"
21+
22+
db, pubsub := dbtestutil.NewDB(t)
23+
deploymentID := uuid.New()
24+
25+
ctx := testutil.Context(t, testutil.WaitLong)
26+
err := db.InsertDeploymentID(ctx, deploymentID.String())
27+
require.NoError(t, err)
28+
29+
client := coderdtest.New(t, &coderdtest.Options{
30+
AppHostname: appHostname,
31+
Database: db,
32+
Pubsub: pubsub,
33+
})
34+
_ = coderdtest.CreateFirstUser(t, client)
35+
36+
regions, err := client.Regions(ctx)
37+
require.NoError(t, err)
38+
39+
require.Len(t, regions, 1)
40+
require.NotEqual(t, uuid.Nil, regions[0].ID)
41+
require.Equal(t, regions[0].ID, deploymentID)
42+
require.Equal(t, "primary", regions[0].Name)
43+
require.Equal(t, "Default", regions[0].DisplayName)
44+
require.NotEmpty(t, regions[0].IconURL)
45+
require.True(t, regions[0].Healthy)
46+
require.Equal(t, client.URL.String(), regions[0].PathAppURL)
47+
require.Equal(t, appHostname, regions[0].WildcardHostname)
48+
49+
// Ensure the primary region ID is constant.
50+
regions2, err := client.Regions(ctx)
51+
require.NoError(t, err)
52+
require.Equal(t, regions[0].ID, regions2[0].ID)
53+
})
54+
55+
t.Run("RequireAuth", func(t *testing.T) {
56+
t.Parallel()
57+
58+
ctx := testutil.Context(t, testutil.WaitLong)
59+
client := coderdtest.New(t, nil)
60+
_ = coderdtest.CreateFirstUser(t, client)
61+
62+
unauthedClient := codersdk.New(client.URL)
63+
regions, err := unauthedClient.Regions(ctx)
64+
require.Error(t, err)
65+
require.Empty(t, regions)
66+
})
67+
}

codersdk/workspaceproxy.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,44 @@ func (c *Client) DeleteWorkspaceProxyByName(ctx context.Context, name string) er
130130
func (c *Client) DeleteWorkspaceProxyByID(ctx context.Context, id uuid.UUID) error {
131131
return c.DeleteWorkspaceProxyByName(ctx, id.String())
132132
}
133+
134+
type RegionsResponse struct {
135+
Regions []Region `json:"regions"`
136+
}
137+
138+
type Region struct {
139+
ID uuid.UUID `json:"id" format:"uuid"`
140+
Name string `json:"name"`
141+
DisplayName string `json:"display_name"`
142+
IconURL string `json:"icon_url"`
143+
Healthy bool `json:"healthy"`
144+
145+
// PathAppURL is the URL to the base path for path apps. Optional
146+
// unless wildcard_hostname is set.
147+
// E.g. https://us.example.com
148+
PathAppURL string `json:"path_app_url"`
149+
150+
// WildcardHostname is the wildcard hostname for subdomain apps.
151+
// E.g. *.us.example.com
152+
// E.g. *--suffix.au.example.com
153+
// Optional. Does not need to be on the same domain as PathAppURL.
154+
WildcardHostname string `json:"wildcard_hostname"`
155+
}
156+
157+
func (c *Client) Regions(ctx context.Context) ([]Region, error) {
158+
res, err := c.Request(ctx, http.MethodGet,
159+
"/api/v2/regions",
160+
nil,
161+
)
162+
if err != nil {
163+
return nil, xerrors.Errorf("make request: %w", err)
164+
}
165+
defer res.Body.Close()
166+
167+
if res.StatusCode != http.StatusOK {
168+
return nil, ReadBodyAsError(res)
169+
}
170+
171+
var regions RegionsResponse
172+
return regions.Regions, json.NewDecoder(res.Body).Decode(&regions)
173+
}

enterprise/coderd/coderd.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ func New(ctx context.Context, options *Options) (*API, error) {
7474

7575
api.AGPL.APIHandler.Group(func(r chi.Router) {
7676
r.Get("/entitlements", api.serveEntitlements)
77+
// /regions overrides the AGPL /regions endpoint
78+
r.Group(func(r chi.Router) {
79+
r.Use(apiKeyMiddleware)
80+
r.Get("/regions", api.regions)
81+
})
7782
r.Route("/replicas", func(r chi.Router) {
7883
r.Use(apiKeyMiddleware)
7984
r.Get("/", api.replicas)
@@ -231,7 +236,7 @@ func New(ctx context.Context, options *Options) (*API, error) {
231236

232237
if api.AGPL.Experiments.Enabled(codersdk.ExperimentMoons) {
233238
// Proxy health is a moon feature.
234-
api.proxyHealth, err = proxyhealth.New(&proxyhealth.Options{
239+
api.ProxyHealth, err = proxyhealth.New(&proxyhealth.Options{
235240
Interval: time.Second * 5,
236241
DB: api.Database,
237242
Logger: options.Logger.Named("proxyhealth"),
@@ -241,7 +246,7 @@ func New(ctx context.Context, options *Options) (*API, error) {
241246
if err != nil {
242247
return nil, xerrors.Errorf("initialize proxy health: %w", err)
243248
}
244-
go api.proxyHealth.Run(ctx)
249+
go api.ProxyHealth.Run(ctx)
245250
// Force the initial loading of the cache. Do this in a go routine in case
246251
// the calls to the workspace proxies hang and this takes some time.
247252
go api.forceWorkspaceProxyHealthUpdate(ctx)
@@ -287,8 +292,8 @@ type API struct {
287292
replicaManager *replicasync.Manager
288293
// Meshes DERP connections from multiple replicas.
289294
derpMesh *derpmesh.Mesh
290-
// proxyHealth checks the reachability of all workspace proxies.
291-
proxyHealth *proxyhealth.ProxyHealth
295+
// ProxyHealth checks the reachability of all workspace proxies.
296+
ProxyHealth *proxyhealth.ProxyHealth
292297

293298
entitlementsMu sync.RWMutex
294299
entitlements codersdk.Entitlements

enterprise/coderd/workspaceproxy.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
agpl "github.com/coder/coder/coderd"
1717
"github.com/coder/coder/coderd/audit"
1818
"github.com/coder/coder/coderd/database"
19+
"github.com/coder/coder/coderd/database/dbauthz"
1920
"github.com/coder/coder/coderd/httpapi"
2021
"github.com/coder/coder/coderd/httpmw"
2122
"github.com/coder/coder/coderd/rbac"
@@ -29,11 +30,60 @@ import (
2930
// forceWorkspaceProxyHealthUpdate forces an update of the proxy health.
3031
// This is useful when a proxy is created or deleted. Errors will be logged.
3132
func (api *API) forceWorkspaceProxyHealthUpdate(ctx context.Context) {
32-
if err := api.proxyHealth.ForceUpdate(ctx); err != nil {
33+
if err := api.ProxyHealth.ForceUpdate(ctx); err != nil {
3334
api.Logger.Error(ctx, "force proxy health update", slog.Error(err))
3435
}
3536
}
3637

38+
// NOTE: this doesn't need a swagger definition since AGPL already has one, and
39+
// this route overrides the AGPL one.
40+
func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
41+
ctx := r.Context()
42+
//nolint:gocritic // this route intentionally requests resources that users
43+
// cannot usually access in order to give them a full list of available
44+
// regions.
45+
ctx = dbauthz.AsSystemRestricted(ctx)
46+
47+
primaryRegion, err := api.AGPL.PrimaryRegion(ctx)
48+
if err != nil {
49+
httpapi.InternalServerError(rw, err)
50+
return
51+
}
52+
regions := []codersdk.Region{primaryRegion}
53+
54+
proxies, err := api.Database.GetWorkspaceProxies(ctx)
55+
if err != nil {
56+
httpapi.InternalServerError(rw, err)
57+
return
58+
}
59+
60+
proxyHealth := api.ProxyHealth.HealthStatus()
61+
for _, proxy := range proxies {
62+
if proxy.Deleted {
63+
continue
64+
}
65+
66+
health, ok := proxyHealth[proxy.ID]
67+
if !ok {
68+
health.Status = proxyhealth.Unknown
69+
}
70+
71+
regions = append(regions, codersdk.Region{
72+
ID: proxy.ID,
73+
Name: proxy.Name,
74+
DisplayName: proxy.DisplayName,
75+
IconURL: proxy.Icon,
76+
Healthy: health.Status == proxyhealth.Healthy,
77+
PathAppURL: proxy.Url,
78+
WildcardHostname: proxy.WildcardHostname,
79+
})
80+
}
81+
82+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse{
83+
Regions: regions,
84+
})
85+
}
86+
3787
// @Summary Delete workspace proxy
3888
// @ID delete-workspace-proxy
3989
// @Security CoderSessionToken
@@ -180,7 +230,7 @@ func (api *API) workspaceProxies(rw http.ResponseWriter, r *http.Request) {
180230
return
181231
}
182232

183-
statues := api.proxyHealth.HealthStatus()
233+
statues := api.ProxyHealth.HealthStatus()
184234
httpapi.Write(ctx, rw, http.StatusOK, convertProxies(proxies, statues))
185235
}
186236

0 commit comments

Comments
 (0)