Skip to content

Commit 9c4de6e

Browse files
committed
chore: Implement workspace proxy going away
When a workspace proxy shuts down, the health status of that proxy should immediately be updated. This is purely a courtesy and technically not required
1 parent 0755ff3 commit 9c4de6e

File tree

8 files changed

+168
-8
lines changed

8 files changed

+168
-8
lines changed

codersdk/workspaceproxy.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import (
1515
type ProxyHealthStatus string
1616

1717
const (
18-
// ProxyReachable means the proxy access url is reachable and returns a healthy
18+
// ProxyHealthy means the proxy access url is reachable and returns a healthy
1919
// status code.
20-
ProxyReachable ProxyHealthStatus = "reachable"
20+
ProxyHealthy ProxyHealthStatus = "ok"
2121
// ProxyUnreachable means the proxy access url is not responding.
2222
ProxyUnreachable ProxyHealthStatus = "unreachable"
2323
// ProxyUnhealthy means the proxy access url is responding, but there is some
@@ -110,6 +110,24 @@ func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
110110
return proxies, json.NewDecoder(res.Body).Decode(&proxies)
111111
}
112112

113+
func (c *Client) WorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) {
114+
res, err := c.Request(ctx, http.MethodGet,
115+
fmt.Sprintf("/api/v2/workspaceproxies/%s", name),
116+
nil,
117+
)
118+
if err != nil {
119+
return WorkspaceProxy{}, xerrors.Errorf("make request: %w", err)
120+
}
121+
defer res.Body.Close()
122+
123+
if res.StatusCode != http.StatusOK {
124+
return WorkspaceProxy{}, ReadBodyAsError(res)
125+
}
126+
127+
var proxy WorkspaceProxy
128+
return proxy, json.NewDecoder(res.Body).Decode(&proxy)
129+
}
130+
113131
func (c *Client) DeleteWorkspaceProxyByName(ctx context.Context, name string) error {
114132
res, err := c.Request(ctx, http.MethodDelete,
115133
fmt.Sprintf("/api/v2/workspaceproxies/%s", name),

enterprise/cli/proxyserver.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
249249
if err != nil {
250250
return xerrors.Errorf("create workspace proxy: %w", err)
251251
}
252+
closers.Add(func() { _ = proxy.Close() })
252253

253254
shutdownConnsCtx, shutdownConns := context.WithCancel(ctx)
254255
defer shutdownConns()

enterprise/coderd/coderd.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,15 @@ func New(ctx context.Context, options *Options) (*API, error) {
113113
)
114114
r.Post("/issue-signed-app-token", api.workspaceProxyIssueSignedAppToken)
115115
r.Post("/register", api.workspaceProxyRegister)
116+
r.Post("/goingaway", api.workspaceProxyGoingAway)
116117
})
117118
r.Route("/{workspaceproxy}", func(r chi.Router) {
118119
r.Use(
119120
apiKeyMiddleware,
120121
httpmw.ExtractWorkspaceProxyParam(api.Database),
121122
)
122123

124+
r.Get("/", api.getWorkspaceProxy)
123125
r.Delete("/", api.deleteWorkspaceProxy)
124126
})
125127
})
@@ -237,7 +239,7 @@ func New(ctx context.Context, options *Options) (*API, error) {
237239
if api.AGPL.Experiments.Enabled(codersdk.ExperimentMoons) {
238240
// Proxy health is a moon feature.
239241
api.ProxyHealth, err = proxyhealth.New(&proxyhealth.Options{
240-
Interval: time.Minute * 1,
242+
Interval: options.ProxyHealthInterval,
241243
DB: api.Database,
242244
Logger: options.Logger.Named("proxyhealth"),
243245
Client: api.HTTPClient,

enterprise/coderd/coderdenttest/coderdenttest.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type Options struct {
4848
EntitlementsUpdateInterval time.Duration
4949
SCIMAPIKey []byte
5050
UserWorkspaceQuota int
51+
ProxyHealthInterval time.Duration
5152
}
5253

5354
// New constructs a codersdk client connected to an in-memory Enterprise API instance.
@@ -74,6 +75,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
7475
Options: oop,
7576
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
7677
Keys: Keys,
78+
ProxyHealthInterval: options.ProxyHealthInterval,
7779
})
7880
assert.NoError(t, err)
7981
setHandler(coderAPI.AGPL.RootHandler)

enterprise/coderd/workspaceproxy.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,7 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
6868
continue
6969
}
7070

71-
health, ok := proxyHealth[proxy.ID]
72-
if !ok {
73-
health.Status = proxyhealth.Unknown
74-
}
75-
71+
health := proxyHealth[proxy.ID]
7672
regions = append(regions, codersdk.Region{
7773
ID: proxy.ID,
7874
Name: proxy.Name,
@@ -254,6 +250,24 @@ func (api *API) workspaceProxies(rw http.ResponseWriter, r *http.Request) {
254250
httpapi.Write(ctx, rw, http.StatusOK, convertProxies(proxies, statues))
255251
}
256252

253+
// @Summary Get workspace proxy
254+
// @ID get-workspace-proxy
255+
// @Security CoderSessionToken
256+
// @Produce json
257+
// @Tags Enterprise
258+
// @Param workspaceproxy path string true "Proxy ID or name" format(uuid)
259+
// @Success 200 {object} codersdk.WorkspaceProxy
260+
// @Router /workspaceproxies/{workspaceproxy} [get]
261+
func (api *API) getWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
262+
var (
263+
ctx = r.Context()
264+
proxy = httpmw.WorkspaceProxyParam(r)
265+
)
266+
267+
status := api.ProxyHealth.HealthStatus()[proxy.ID]
268+
httpapi.Write(ctx, rw, http.StatusOK, convertProxy(proxy, status))
269+
}
270+
257271
// @Summary Issue signed workspace app token
258272
// @ID issue-signed-workspace-app-token
259273
// @Security CoderSessionToken
@@ -371,6 +385,35 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
371385
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
372386
}
373387

388+
// workspaceProxyGoingAway is used to tell coderd that the workspace proxy is
389+
// shutting down and going away. The main purpose of this function is for the
390+
// health status of the workspace proxy to be more quickly updated when we know
391+
// that the proxy is going to be unhealthy. This does not delete the workspace
392+
// or cause any other side effects.
393+
// If the workspace proxy comes back online, even without a register, it will
394+
// be found healthy again by the normal checks.
395+
// @Summary Workspace proxy going away
396+
// @ID workspace-proxy-going-away
397+
// @Security CoderSessionToken
398+
// @Produce json
399+
// @Tags Enterprise
400+
// @Success 201 {object} codersdk.Response
401+
// @Router /workspaceproxies/me/goingaway [post]
402+
// @x-apidocgen {"skip": true}
403+
func (api *API) workspaceProxyGoingAway(rw http.ResponseWriter, r *http.Request) {
404+
var (
405+
ctx = r.Context()
406+
)
407+
408+
// Force a health update to happen immediately. The proxy should
409+
// not return a successful response if it is going away.
410+
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
411+
412+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
413+
Message: "OK",
414+
})
415+
}
416+
374417
// reconnectingPTYSignedToken issues a signed app token for use when connecting
375418
// to the reconnecting PTY websocket on an external workspace proxy. This is set
376419
// by the client as a query parameter when connecting.
@@ -476,6 +519,9 @@ func convertProxies(p []database.WorkspaceProxy, statuses map[uuid.UUID]proxyhea
476519
}
477520

478521
func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) codersdk.WorkspaceProxy {
522+
if status.Status == "" {
523+
status.Status = proxyhealth.Unknown
524+
}
479525
return codersdk.WorkspaceProxy{
480526
ID: p.ID,
481527
Name: p.Name,

enterprise/coderd/workspaceproxy_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http/httputil"
88
"net/url"
99
"testing"
10+
"time"
1011

1112
"github.com/google/uuid"
1213
"github.com/moby/moby/pkg/namesgenerator"
@@ -172,6 +173,66 @@ func TestRegions(t *testing.T) {
172173
require.Error(t, err)
173174
require.Empty(t, regions)
174175
})
176+
177+
t.Run("GoingAway", func(t *testing.T) {
178+
t.Parallel()
179+
180+
dv := coderdtest.DeploymentValues(t)
181+
dv.Experiments = []string{
182+
string(codersdk.ExperimentMoons),
183+
"*",
184+
}
185+
186+
db, pubsub := dbtestutil.NewDB(t)
187+
188+
ctx := testutil.Context(t, testutil.WaitLong)
189+
190+
client, closer, api := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
191+
Options: &coderdtest.Options{
192+
AppHostname: appHostname,
193+
Database: db,
194+
Pubsub: pubsub,
195+
DeploymentValues: dv,
196+
},
197+
// The interval is set to 1 hour so the proxy health
198+
// check will never happen manually. All checks will be
199+
// forced updates.
200+
ProxyHealthInterval: time.Hour,
201+
})
202+
t.Cleanup(func() {
203+
_ = closer.Close()
204+
})
205+
_ = coderdtest.CreateFirstUser(t, client)
206+
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
207+
Features: license.Features{
208+
codersdk.FeatureWorkspaceProxy: 1,
209+
},
210+
})
211+
212+
const proxyName = "testproxy"
213+
proxy := coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
214+
Name: proxyName,
215+
})
216+
var _ = proxy
217+
218+
require.Eventuallyf(t, func() bool {
219+
proxy, err := client.WorkspaceProxyByName(ctx, proxyName)
220+
if err != nil {
221+
return false
222+
}
223+
return proxy.Status.Status == codersdk.ProxyHealthy
224+
}, time.Second*10, time.Millisecond*100, "proxy never became healthy")
225+
226+
_ = proxy.Close()
227+
// The proxy should tell the primary on close that is is no longer healthy.
228+
require.Eventuallyf(t, func() bool {
229+
proxy, err := client.WorkspaceProxyByName(ctx, proxyName)
230+
if err != nil {
231+
return false
232+
}
233+
return proxy.Status.Status == codersdk.ProxyUnhealthy
234+
}, time.Second*10, time.Millisecond*100, "proxy never became unhealthy after close")
235+
})
175236
}
176237

177238
func TestWorkspaceProxyCRUD(t *testing.T) {

enterprise/wsproxy/wsproxy.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
250250

251251
func (s *Server) Close() error {
252252
s.cancel()
253+
go func() {
254+
// Do this in a go routine to not block the close. This is allowed
255+
// to fail, it is just a courtesy to the dashboard.
256+
_ = s.SDKClient.WorkspaceProxyGoingAway(context.Background())
257+
}()
253258
return s.AppServer.Close()
254259
}
255260

@@ -279,6 +284,15 @@ func (s *Server) healthReport(rw http.ResponseWriter, r *http.Request) {
279284
ctx := r.Context()
280285
var report codersdk.ProxyHealthReport
281286

287+
// This is to catch edge cases where the server is shutting down, but might
288+
// still serve a web request that returns "healthy". This is mainly just for
289+
// unit tests, as shutting down the test webserver is tied to the lifecycle
290+
// of the test. In practice, the webserver is tied to the lifecycle of the
291+
// app, so the webserver AND the proxy will be shut down at the same time.
292+
if s.ctx.Err() != nil {
293+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, "workspace proxy in middle of shutting down")
294+
}
295+
282296
// Hit the build info to do basic version checking.
283297
primaryBuild, err := s.SDKClient.SDKClient.BuildInfo(ctx)
284298
if err != nil {

enterprise/wsproxy/wsproxysdk/wsproxysdk.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,19 @@ func (c *Client) RegisterWorkspaceProxy(ctx context.Context, req RegisterWorkspa
170170
var resp RegisterWorkspaceProxyResponse
171171
return resp, json.NewDecoder(res.Body).Decode(&resp)
172172
}
173+
174+
func (c *Client) WorkspaceProxyGoingAway(ctx context.Context) error {
175+
res, err := c.Request(ctx, http.MethodPost,
176+
"/api/v2/workspaceproxies/me/goingaway",
177+
nil,
178+
)
179+
if err != nil {
180+
return xerrors.Errorf("make request: %w", err)
181+
}
182+
defer res.Body.Close()
183+
184+
if res.StatusCode != http.StatusOK {
185+
return codersdk.ReadBodyAsError(res)
186+
}
187+
return nil
188+
}

0 commit comments

Comments
 (0)