Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
487ee95
feat(site): use websocket connection for devcontainer updates
DanielleMaywood Jul 3, 2025
cc42018
fix: some issues
DanielleMaywood Jul 7, 2025
fa46517
Merge branch 'main' into danielle/container-push
DanielleMaywood Jul 7, 2025
5aef560
Merge branch 'main' into danielle/container-push
DanielleMaywood Jul 8, 2025
975ef8b
chore: fix disconnect bug and add agentcontainers test
DanielleMaywood Jul 8, 2025
8fdeca3
Merge branch 'main' into danielle/container-push
DanielleMaywood Jul 9, 2025
6da941f
test: add coderd/ test
DanielleMaywood Jul 9, 2025
ff5725e
chore: appease formatter
DanielleMaywood Jul 9, 2025
178507c
chore: feedback
DanielleMaywood Jul 9, 2025
367b87d
chore: fix nil exception
DanielleMaywood Jul 9, 2025
34b17c4
chore: make gen
DanielleMaywood Jul 9, 2025
8f12460
fix: docs
DanielleMaywood Jul 9, 2025
81022fa
Merge branch 'main' into danielle/container-push
DanielleMaywood Jul 9, 2025
1768f7b
fix: only send when there are updates
DanielleMaywood Jul 9, 2025
8240663
chore: lint and format
DanielleMaywood Jul 9, 2025
6d97960
Merge branch 'main' into danielle/container-push
DanielleMaywood Jul 10, 2025
88a611d
chore: test `useAgentContainers`
DanielleMaywood Jul 10, 2025
001ccda
chore: check container ids match in `Equals` function
DanielleMaywood Jul 10, 2025
3e50965
chore: add logger to WatchContainers
DanielleMaywood Jul 10, 2025
6ce5c19
chore: reposition close of update channel
DanielleMaywood Jul 10, 2025
cd0c2d5
chore: rename `knownDevcontainers`
DanielleMaywood Jul 10, 2025
04a92a4
chore: use `WebsocketNetConn`
DanielleMaywood Jul 10, 2025
096a85e
chore: steal CloseRead
DanielleMaywood Jul 10, 2025
971f9d6
chore: check agents match
DanielleMaywood Jul 10, 2025
f24401f
test: parsing error and socket error
DanielleMaywood Jul 10, 2025
64d9252
chore: lint and format
DanielleMaywood Jul 10, 2025
40c3fd9
chore: give comment some love
DanielleMaywood Jul 14, 2025
1cda455
chore: re-use json encoder instead of recreating every time
DanielleMaywood Jul 14, 2025
2ded15f
fix: push initial dev container state in websocket
DanielleMaywood Jul 14, 2025
a87f388
fix: do not invalidateQuery + fix bad types
DanielleMaywood Jul 14, 2025
2de01f5
chore: appease linter
DanielleMaywood Jul 14, 2025
00fdae6
chore: broadcast updates in more places, add staleTime: Infinity
DanielleMaywood Jul 14, 2025
a4a4bb2
chore: appease linter
DanielleMaywood Jul 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: only send when there are updates
  • Loading branch information
DanielleMaywood committed Jul 9, 2025
commit 1768f7b275cc39cb487748efb7b0383e55f2092c
27 changes: 22 additions & 5 deletions agent/agentcontainers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"net/http"
"os"
"path"
Expand Down Expand Up @@ -646,13 +647,29 @@ func (api *API) updateContainers(ctx context.Context) error {
api.mu.Lock()
defer api.mu.Unlock()

var knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Naming so nobody accidentally updates this map rather than api.knownDevcontainers. Perhaps knownDevcontainersBackup or something along those lines those lines.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cd0c2d5

if len(api.updateChans) > 0 {
knownDevcontainers = maps.Clone(api.knownDevcontainers)
}

api.processUpdatedContainersLocked(ctx, updated)

// Broadcast our updates
for _, ch := range api.updateChans {
select {
case ch <- struct{}{}:
default:
if len(api.updateChans) > 0 {
statesAreEqual := maps.EqualFunc(
knownDevcontainers,
api.knownDevcontainers,
func(dc1, dc2 codersdk.WorkspaceAgentDevcontainer) bool {
return dc1.Equals(dc2)
})

if !statesAreEqual {
// Broadcast our updates
for _, ch := range api.updateChans {
select {
case ch <- struct{}{}:
default:
}
}
}
}

Expand Down
119 changes: 102 additions & 17 deletions agent/agentcontainers/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,14 +445,95 @@ func TestAPI(t *testing.T) {
t.Run("Watch", func(t *testing.T) {
t.Parallel()

fakeContainer1 := fakeContainer(t)
fakeContainer2 := fakeContainer(t)
fakeContainer3 := fakeContainer(t)

makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse {
return codersdk.WorkspaceAgentListContainersResponse{Containers: cts}
}

fakeContainer1 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) {
c.ID = "container1"
c.FriendlyName = "devcontainer1"
c.Image = "busybox:latest"
c.Labels = map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json",
}
})

fakeContainer2 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) {
c.ID = "container2"
c.FriendlyName = "devcontainer2"
c.Image = "ubuntu:latest"
c.Labels = map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json",
}
})

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,
},
},
},
},
}

var (
ctx = testutil.Context(t, testutil.WaitShort)
mClock = quartz.NewMock(t)
Expand All @@ -467,7 +548,7 @@ func TestAPI(t *testing.T) {
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mLister),
agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer api.Close()
Expand All @@ -484,16 +565,10 @@ func TestAPI(t *testing.T) {
defer res.Body.Close()
}

for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{
makeResponse(),
makeResponse(fakeContainer1),
makeResponse(fakeContainer1, fakeContainer2),
makeResponse(fakeContainer1, fakeContainer2, fakeContainer3),
makeResponse(fakeContainer1, fakeContainer2),
makeResponse(fakeContainer1),
makeResponse(),
} {
mLister.EXPECT().List(gomock.Any()).Return(mockResponse, nil)
mLister.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("<none>", nil).AnyTimes()

for _, stage := range stages {
mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil)

// Given: We allow the update loop to progress
_, aw := mClock.AdvanceNext()
Expand All @@ -504,11 +579,21 @@ func TestAPI(t *testing.T) {
require.NoError(t, err)
require.Equal(t, websocket.MessageText, mt)

// Then: We expect the receieved message matches the mocked response.
// Then: We expect the receieved message matches the expected response.
var got codersdk.WorkspaceAgentListContainersResponse
err = json.Unmarshal(msg, &got)
require.NoError(t, err)
require.Equal(t, mockResponse, got)

require.Equal(t, stage.expected.Containers, got.Containers)
require.Len(t, got.Devcontainers, len(stage.expected.Devcontainers))
for j, expectedDev := range stage.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)
}
}
})

Expand Down
114 changes: 99 additions & 15 deletions coderd/workspaceagents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1406,14 +1406,27 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) {
return agents
}).Do()

devContainer = codersdk.WorkspaceAgentContainer{
ID: uuid.NewString(),
fakeContainer1 = codersdk.WorkspaceAgentContainer{
ID: "container1",
CreatedAt: dbtime.Now(),
FriendlyName: testutil.GetRandomName(t),
FriendlyName: "container1",
Image: "busybox:latest",
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project",
agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project/.devcontainer/devcontainer.json",
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",
Expand All @@ -1424,6 +1437,71 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) {
}
)

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,
},
},
},
},
}

mCCLI.EXPECT().List(gomock.Any()).Return(makeResponse(), nil)

_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
Expand All @@ -1433,7 +1511,6 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) {
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"),
}
})

Expand All @@ -1451,23 +1528,30 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) {
closer.Close()
}()

for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{
makeResponse(),
makeResponse(devContainer),
makeResponse(),
} {
mCCLI.EXPECT().List(gomock.Any()).Return(mockResponse, nil)
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("<none>", nil).AnyTimes()
for _, stage := range stages {
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil)

_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)

var resp codersdk.WorkspaceAgentListContainersResponse
var got codersdk.WorkspaceAgentListContainersResponse
select {
case <-ctx.Done():
case resp = <-containers:
case got = <-containers:
}
require.NoError(t, ctx.Err())
require.Equal(t, mockResponse, resp)

require.Equal(t, stage.expected.Containers, got.Containers)
require.Len(t, got.Devcontainers, len(stage.expected.Devcontainers))
for j, expectedDev := range stage.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)
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions codersdk/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,15 @@ type WorkspaceAgentDevcontainer struct {
Error string `json:"error,omitempty"`
}

func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) bool {
return d.ID == other.ID &&
d.Name == other.Name &&
d.WorkspaceFolder == other.WorkspaceFolder &&
d.Status == other.Status &&
d.Dirty == other.Dirty &&
d.Error == other.Error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also check container and agent, so something like this?

Suggested change
d.Error == other.Error
d.Error == other.Error &&
(d.Container == nil && other.Container == nil || (d.Container != nil && other.Continer != nil && d.Container.ID == other.Container.ID)) &&
(d.Agent == nil && ... d.Agent.ID == ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 001ccda

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see d.Agent checked? IMO we should propagate changes to it as well since that's how the frontend associates the sub agent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh silly me I only read half the sentence 🤦‍♀️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed again in 971f9d6 😅

}

// WorkspaceAgentDevcontainerAgent represents the sub agent for a
// devcontainer.
type WorkspaceAgentDevcontainerAgent struct {
Expand Down
Loading
Loading