-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathwatcher.go
More file actions
333 lines (307 loc) · 9 KB
/
Copy pathwatcher.go
File metadata and controls
333 lines (307 loc) · 9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
package workspaceconnwatcher
import (
"context"
"database/sql"
"errors"
"net/http"
"sync"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/wspubsub"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
type Watcher struct {
logger slog.Logger
sub pubsub.Subscriber
db database.Store
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
wg sync.WaitGroup
closed bool
}
type event struct {
sync bool
wsEvent *wspubsub.WorkspaceEvent
}
func New(ctx context.Context, logger slog.Logger, sub pubsub.Subscriber, db database.Store) *Watcher {
ctx, cancel := context.WithCancel(ctx)
w := &Watcher{
logger: logger.Named("wsconnwatcher"),
ctx: ctx,
cancel: cancel,
sub: sub,
db: db,
}
go func() {
<-ctx.Done()
w.Close()
}()
return w
}
// @Summary Workspace Agent Connection Watch
// @ID workspace-agent-connection-watch
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 101 {object} workspacesdk.ConnectionWatchEvent
// @Router /api/v2/workspaces/{workspace}/agent-connection-watch [get]
func (w *Watcher) WorkspaceAgentConnectionWatch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspace := httpmw.WorkspaceParam(r)
agentName := r.URL.Query().Get("agent_name")
filteredEvents := make(chan event, 1)
filteredEvents <- event{sync: true} // init sync
cancelWorkspaceSubscribe, err := w.sub.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID),
wspubsub.HandleWorkspaceEvent(
func(ctx context.Context, payload wspubsub.WorkspaceEvent, err error) {
if err != nil {
// subscription error, resync
select {
case filteredEvents <- event{sync: true}:
case <-ctx.Done():
}
return
}
if payload.WorkspaceID != workspace.ID {
return
}
select {
case filteredEvents <- event{wsEvent: &payload}:
case <-ctx.Done():
}
}))
if err != nil {
w.logger.Error(ctx, "failed to subscribe to workspace events",
slog.Error(err), slog.F("owner_id", workspace.OwnerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error setting up workspace event subscription",
// Don't include the error in case it leaks infra details about the pubsub
})
return
}
defer cancelWorkspaceSubscribe()
closed := false
w.mu.Lock()
closed = w.closed
if !closed {
w.wg.Add(1)
}
w.mu.Unlock()
if closed {
w.logger.Debug(ctx, "server is closed, writing error")
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
Message: "Server instance is shutting down",
})
return
}
defer w.wg.Done()
conn, err := websocket.Accept(rw, r, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to accept WebSocket.",
Detail: err.Error(),
})
return
}
// CloseRead starts a goroutine to read and discard messages from the client,
// including Pong messages sent in response to our Ping heartbeats.
_ = conn.CloseRead(ctx)
ctx, cancel := context.WithCancel(ctx)
go httpapi.HeartbeatClose(ctx, w.logger, cancel, conn)
defer cancel()
u := &updater{
db: w.db,
watcherCtx: w.ctx,
connCtx: ctx,
conn: conn,
workspaceID: workspace.ID,
events: filteredEvents,
agentName: agentName,
}
u.run()
}
func (w *Watcher) Close() {
w.mu.Lock()
w.closed = true
w.mu.Unlock()
w.cancel()
w.wg.Wait()
}
type updater struct {
db database.Store
watcherCtx context.Context
connCtx context.Context
conn *websocket.Conn
enc *wsjson.Encoder[workspacesdk.ConnectionWatchEvent]
workspaceID uuid.UUID
events <-chan event
agentName string
lastBuild database.GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow
}
func (u *updater) run() {
u.enc = wsjson.NewEncoder[workspacesdk.ConnectionWatchEvent](u.conn, websocket.MessageText)
defer func() {
// this is a no-op if we have already closed for some other reason.
_ = u.enc.Close(websocket.StatusNormalClosure)
}()
for {
select {
case <-u.watcherCtx.Done():
u.errorThenClose(workspacesdk.WatchError{
Code: workspacesdk.WatchErrorServerShutdown,
Retryable: true,
Message: "server is shutting down",
})
return
case <-u.connCtx.Done():
return
case e := <-u.events:
if e.sync {
// zero this out so we'll send a full update
u.lastBuild = database.GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow{}
if !u.buildUpdate() {
return
}
}
if e.wsEvent != nil {
switch e.wsEvent.Kind {
case wspubsub.WorkspaceEventKindStateChange:
if !u.buildUpdate() {
return
}
case wspubsub.WorkspaceEventKindAgentLifecycleUpdate:
if !u.maybeSendAgentUpdate() {
return
}
}
}
}
}
}
func (u *updater) buildUpdate() bool {
build, err := u.db.GetLatestWorkspaceBuildWithStatusByWorkspaceID(u.connCtx, u.workspaceID)
if err != nil {
retryable := true
details := err.Error()
if errors.Is(err, sql.ErrNoRows) {
// There is no build (unlikely), or the workspace was deleted. In both cases, retrying won't help.
retryable = false
}
if dbauthz.IsNotAuthorizedError(err) {
retryable = false
details = "unauthorized" // security: don't leak internal authz details
}
u.errorThenClose(workspacesdk.WatchError{
Code: workspacesdk.WatchErrorDatabase,
Retryable: retryable,
Message: "failed to fetch latest workspace build",
Details: details,
})
return false
}
if build.BuildNumber != u.lastBuild.BuildNumber ||
build.JobStatus != u.lastBuild.JobStatus ||
build.Transition != u.lastBuild.Transition {
u.lastBuild = build
err = u.enc.Encode(workspacesdk.ConnectionWatchEvent{BuildUpdate: &workspacesdk.BuildUpdate{
Transition: codersdk.WorkspaceTransition(build.Transition),
JobStatus: codersdk.ProvisionerJobStatus(build.JobStatus),
}})
if err != nil {
// probably this is just that the connection is closed, but in case there is some actual JSON serialization
// error, send a close frame.
_ = u.conn.Close(websocket.StatusInternalError, "failed to encode build update")
return false
}
return u.maybeSendAgentUpdate()
}
return true
}
func (u *updater) maybeSendAgentUpdate() (ok bool) {
if u.lastBuild.Transition != database.WorkspaceTransitionStart ||
u.lastBuild.JobStatus != database.ProvisionerJobStatusSucceeded {
// only send agent updates for successfully started workspaces
return true
}
agents, err := u.db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(u.connCtx,
database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
WorkspaceID: u.workspaceID,
BuildNumber: u.lastBuild.BuildNumber,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
details := err.Error()
retryable := true
if dbauthz.IsNotAuthorizedError(err) {
retryable = false
details = "unauthorized"
}
u.errorThenClose(workspacesdk.WatchError{
Code: workspacesdk.WatchErrorDatabase,
Retryable: retryable,
Message: "failed to fetch workspace agents",
Details: details,
})
return false
}
if len(agents) == 0 {
u.errorThenClose(workspacesdk.WatchError{
Code: workspacesdk.WatchErrorNoAgents,
Retryable: false,
Message: "no agents found for workspace",
})
return false
}
if len(agents) > 1 && u.agentName == "" {
u.errorThenClose(workspacesdk.WatchError{
Code: workspacesdk.WatchErrorTooManyAgents,
Retryable: false,
Message: "more than one agent on workspace and target not specified",
})
return false
}
var agent database.WorkspaceAgent
if u.agentName == "" {
agent = agents[0]
} else {
for _, a := range agents {
if a.Name == u.agentName {
agent = a
break
}
}
if agent.ID == uuid.Nil {
u.errorThenClose(workspacesdk.WatchError{
Code: workspacesdk.WatchErrorNameNotFound,
Retryable: false,
Message: "target agent not found by name",
})
return false
}
}
err = u.enc.Encode(workspacesdk.ConnectionWatchEvent{AgentUpdate: &workspacesdk.AgentUpdate{
Lifecycle: codersdk.WorkspaceAgentLifecycle(agent.LifecycleState),
ID: agent.ID,
}})
if err != nil {
// probably this is just that the connection is closed, but in case there is some actual JSON serialization
// error, send a close frame.
_ = u.conn.Close(websocket.StatusInternalError, "failed to encode agent update")
return false
}
return true
}
func (u *updater) errorThenClose(err workspacesdk.WatchError) {
_ = u.enc.Encode(workspacesdk.ConnectionWatchEvent{Error: &err})
// ignore encoding errors above because in any case, we are going to close the connection.
_ = u.conn.Close(websocket.StatusNormalClosure, "error")
}