mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): use websocket connection for devcontainer updates (#18808)
Instead of polling every 10 seconds, we instead use a WebSocket connection for more timely updates.
This commit is contained in:
Generated
+35
@@ -8778,6 +8778,41 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/containers/watch": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Agents"
|
||||
],
|
||||
"summary": "Watch workspace agent for container updates.",
|
||||
"operationId": "watch-workspace-agent-for-container-updates",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace agent ID",
|
||||
"name": "workspaceagent",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/coordinate": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
||||
Generated
+31
@@ -7751,6 +7751,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/containers/watch": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Agents"],
|
||||
"summary": "Watch workspace agent for container updates.",
|
||||
"operationId": "watch-workspace-agent-for-container-updates",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace agent ID",
|
||||
"name": "workspaceagent",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/coordinate": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
||||
@@ -1351,6 +1351,7 @@ func New(options *Options) *API {
|
||||
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
|
||||
r.Get("/connection", api.workspaceAgentConnection)
|
||||
r.Get("/containers", api.workspaceAgentListContainers)
|
||||
r.Get("/containers/watch", api.watchWorkspaceAgentContainers)
|
||||
r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer)
|
||||
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
|
||||
|
||||
|
||||
@@ -801,6 +801,106 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req
|
||||
httpapi.Write(ctx, rw, http.StatusOK, portsResponse)
|
||||
}
|
||||
|
||||
// @Summary Watch workspace agent for container updates.
|
||||
// @ID watch-workspace-agent-for-container-updates
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Agents
|
||||
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
||||
// @Success 200 {object} codersdk.WorkspaceAgentListContainersResponse
|
||||
// @Router /workspaceagents/{workspaceagent}/containers/watch [get]
|
||||
func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
workspaceAgent = httpmw.WorkspaceAgentParam(r)
|
||||
)
|
||||
|
||||
// If the agent is unreachable, the request will hang. Assume that if we
|
||||
// don't get a response after 30s that the agent is unreachable.
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
apiAgent, err := db2sdk.WorkspaceAgent(
|
||||
api.DERPMap(),
|
||||
*api.TailnetCoordinator.Load(),
|
||||
workspaceAgent,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
api.AgentInactiveDisconnectTimeout,
|
||||
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
||||
)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error reading workspace agent.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error dialing workspace agent.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
watcherLogger := api.Logger.Named("agent_container_watcher").With(slog.F("agent_id", workspaceAgent.ID))
|
||||
containersCh, closer, err := agentConn.WatchContainers(ctx, watcherLogger)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error watching agent's containers.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
conn, err := websocket.Accept(rw, r, nil)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to upgrade connection to websocket.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Here we close the websocket for reading, so that the websocket library will handle pings and
|
||||
// close frames.
|
||||
_ = conn.CloseRead(context.Background())
|
||||
|
||||
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
|
||||
defer wsNetConn.Close()
|
||||
|
||||
go httpapi.Heartbeat(ctx, conn)
|
||||
|
||||
encoder := json.NewEncoder(wsNetConn)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-api.ctx.Done():
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case containers := <-containersCh:
|
||||
if err := encoder.Encode(containers); err != nil {
|
||||
api.Logger.Error(ctx, "encode containers", slog.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Get running containers for workspace agent
|
||||
// @ID get-running-containers-for-workspace-agent
|
||||
// @Security CoderSessionToken
|
||||
|
||||
@@ -1386,6 +1386,192 @@ func TestWorkspaceAgentContainers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWatchWorkspaceAgentDevcontainers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitLong)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
mClock = quartz.NewMock(t)
|
||||
updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop")
|
||||
mCtrl = gomock.NewController(t)
|
||||
mCCLI = acmock.NewMockContainerCLI(mCtrl)
|
||||
|
||||
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &logger})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
r = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
return agents
|
||||
}).Do()
|
||||
|
||||
fakeContainer1 = codersdk.WorkspaceAgentContainer{
|
||||
ID: "container1",
|
||||
CreatedAt: dbtime.Now(),
|
||||
FriendlyName: "container1",
|
||||
Image: "busybox:latest",
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json",
|
||||
},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
}
|
||||
|
||||
fakeContainer2 = codersdk.WorkspaceAgentContainer{
|
||||
ID: "container1",
|
||||
CreatedAt: dbtime.Now(),
|
||||
FriendlyName: "container2",
|
||||
Image: "busybox:latest",
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json",
|
||||
},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
}
|
||||
)
|
||||
|
||||
stages := []struct {
|
||||
containers []codersdk.WorkspaceAgentContainer
|
||||
expected codersdk.WorkspaceAgentListContainersResponse
|
||||
}{
|
||||
{
|
||||
containers: []codersdk.WorkspaceAgentContainer{fakeContainer1},
|
||||
expected: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1},
|
||||
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
Name: "project1",
|
||||
WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel],
|
||||
ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel],
|
||||
Status: "running",
|
||||
Container: &fakeContainer1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2},
|
||||
expected: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2},
|
||||
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
Name: "project1",
|
||||
WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel],
|
||||
ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel],
|
||||
Status: "running",
|
||||
Container: &fakeContainer1,
|
||||
},
|
||||
{
|
||||
Name: "project2",
|
||||
WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel],
|
||||
ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel],
|
||||
Status: "running",
|
||||
Container: &fakeContainer2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
containers: []codersdk.WorkspaceAgentContainer{fakeContainer2},
|
||||
expected: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2},
|
||||
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
Name: "",
|
||||
WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel],
|
||||
ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel],
|
||||
Status: "stopped",
|
||||
Container: nil,
|
||||
},
|
||||
{
|
||||
Name: "project2",
|
||||
WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel],
|
||||
ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel],
|
||||
Status: "running",
|
||||
Container: &fakeContainer2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Set up initial state for immediate send on connection
|
||||
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil)
|
||||
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("<none>", nil).AnyTimes()
|
||||
|
||||
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
|
||||
o.Logger = logger.Named("agent")
|
||||
o.Devcontainers = true
|
||||
o.DevcontainerAPIOptions = []agentcontainers.Option{
|
||||
agentcontainers.WithClock(mClock),
|
||||
agentcontainers.WithContainerCLI(mCCLI),
|
||||
agentcontainers.WithWatcher(watcher.NewNoop()),
|
||||
}
|
||||
})
|
||||
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
|
||||
require.Len(t, resources, 1, "expected one resource")
|
||||
require.Len(t, resources[0].Agents, 1, "expected one agent")
|
||||
agentID := resources[0].Agents[0].ID
|
||||
|
||||
updaterTickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||
defer updaterTickerTrap.Close()
|
||||
|
||||
containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
closer.Close()
|
||||
}()
|
||||
|
||||
// Read initial state sent immediately on connection
|
||||
var got codersdk.WorkspaceAgentListContainersResponse
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case got = <-containers:
|
||||
}
|
||||
require.NoError(t, ctx.Err())
|
||||
|
||||
require.Equal(t, stages[0].expected.Containers, got.Containers)
|
||||
require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers))
|
||||
for j, expectedDev := range stages[0].expected.Devcontainers {
|
||||
gotDev := got.Devcontainers[j]
|
||||
require.Equal(t, expectedDev.Name, gotDev.Name)
|
||||
require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder)
|
||||
require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath)
|
||||
require.Equal(t, expectedDev.Status, gotDev.Status)
|
||||
require.Equal(t, expectedDev.Container, gotDev.Container)
|
||||
}
|
||||
|
||||
// Process remaining stages through updater loop
|
||||
for i, stage := range stages[1:] {
|
||||
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil)
|
||||
|
||||
_, aw := mClock.AdvanceNext()
|
||||
aw.MustWait(ctx)
|
||||
|
||||
var got codersdk.WorkspaceAgentListContainersResponse
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case got = <-containers:
|
||||
}
|
||||
require.NoError(t, ctx.Err())
|
||||
|
||||
require.Equal(t, stages[i+1].expected.Containers, got.Containers)
|
||||
require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers))
|
||||
for j, expectedDev := range stages[i+1].expected.Devcontainers {
|
||||
gotDev := got.Devcontainers[j]
|
||||
require.Equal(t, expectedDev.Name, gotDev.Name)
|
||||
require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder)
|
||||
require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath)
|
||||
require.Equal(t, expectedDev.Status, gotDev.Status)
|
||||
require.Equal(t, expectedDev.Container, gotDev.Container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user