Skip to content

Commit 2b7316b

Browse files
committed
refactor(agent/agentcontainers): Implement API service
1 parent 25fb34c commit 2b7316b

9 files changed

+820
-765
lines changed

agent/agentcontainers/api.go

+205
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package agentcontainers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"slices"
8+
"time"
9+
10+
"github.com/go-chi/chi/v5"
11+
"golang.org/x/xerrors"
12+
13+
"cdr.dev/slog"
14+
"github.com/coder/coder/v2/agent/agentexec"
15+
"github.com/coder/coder/v2/coderd/httpapi"
16+
"github.com/coder/coder/v2/codersdk"
17+
"github.com/coder/quartz"
18+
)
19+
20+
const (
21+
defaultGetContainersCacheDuration = 10 * time.Second
22+
dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST"
23+
getContainersTimeout = 5 * time.Second
24+
)
25+
26+
// API is responsible for container-related operations in the agent.
27+
// It provides methods to list and manage containers.
28+
type API struct {
29+
cacheDuration time.Duration
30+
cl Lister
31+
dccli DevcontainerCLI
32+
clock quartz.Clock
33+
34+
// lockCh protects the below fields. We use a channel instead of a mutex so we
35+
// can handle cancellation properly.
36+
lockCh chan struct{}
37+
containers codersdk.WorkspaceAgentListContainersResponse
38+
mtime time.Time
39+
}
40+
41+
// Option is a functional option for API.
42+
type Option func(*API)
43+
44+
// WithLister sets the agentcontainers.Lister implementation to use.
45+
// The default implementation uses the Docker CLI to list containers.
46+
func WithLister(cl Lister) Option {
47+
return func(api *API) {
48+
api.cl = cl
49+
}
50+
}
51+
52+
func WithDevcontainerCLI(dccli DevcontainerCLI) Option {
53+
return func(api *API) {
54+
api.dccli = dccli
55+
}
56+
}
57+
58+
// NewAPI returns a new API with the given options applied.
59+
func NewAPI(logger slog.Logger, options ...Option) *API {
60+
api := &API{
61+
clock: quartz.NewReal(),
62+
cacheDuration: defaultGetContainersCacheDuration,
63+
lockCh: make(chan struct{}, 1),
64+
}
65+
for _, opt := range options {
66+
opt(api)
67+
}
68+
if api.cl == nil {
69+
api.cl = &DockerCLILister{}
70+
}
71+
if api.dccli == nil {
72+
api.dccli = NewDevcontainerCLI(logger, agentexec.DefaultExecer)
73+
}
74+
75+
return api
76+
}
77+
78+
// Routes returns the HTTP handler for container-related routes.
79+
func (api *API) Routes() http.Handler {
80+
r := chi.NewRouter()
81+
r.Get("/", api.handleList)
82+
r.Post("/{id}/recreate", api.handleRecreate)
83+
return r
84+
}
85+
86+
// handleList handles the HTTP request to list containers.
87+
func (api *API) handleList(rw http.ResponseWriter, r *http.Request) {
88+
select {
89+
case <-r.Context().Done():
90+
// Client went away.
91+
return
92+
default:
93+
ct, err := api.getContainers(r.Context())
94+
if err != nil {
95+
if errors.Is(err, context.Canceled) {
96+
httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{
97+
Message: "Could not get containers.",
98+
Detail: "Took too long to list containers.",
99+
})
100+
return
101+
}
102+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
103+
Message: "Could not get containers.",
104+
Detail: err.Error(),
105+
})
106+
return
107+
}
108+
109+
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
110+
}
111+
}
112+
113+
func copyListContainersResponse(resp codersdk.WorkspaceAgentListContainersResponse) codersdk.WorkspaceAgentListContainersResponse {
114+
return codersdk.WorkspaceAgentListContainersResponse{
115+
Containers: slices.Clone(resp.Containers),
116+
Warnings: slices.Clone(resp.Warnings),
117+
}
118+
}
119+
120+
func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
121+
select {
122+
case <-ctx.Done():
123+
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
124+
default:
125+
api.lockCh <- struct{}{}
126+
}
127+
defer func() {
128+
<-api.lockCh
129+
}()
130+
131+
now := api.clock.Now()
132+
if now.Sub(api.mtime) < api.cacheDuration {
133+
return copyListContainersResponse(api.containers), nil
134+
}
135+
136+
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
137+
defer timeoutCancel()
138+
updated, err := api.cl.List(timeoutCtx)
139+
if err != nil {
140+
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
141+
}
142+
api.containers = updated
143+
api.mtime = now
144+
145+
return copyListContainersResponse(api.containers), nil
146+
}
147+
148+
// handleRecreate handles the HTTP request to recreate a container.
149+
func (api *API) handleRecreate(w http.ResponseWriter, r *http.Request) {
150+
ctx := r.Context()
151+
id := chi.URLParam(r, "id")
152+
153+
if id == "" {
154+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
155+
Message: "Missing container ID or name",
156+
Detail: "Container ID or name is required to recreate a devcontainer.",
157+
})
158+
return
159+
}
160+
161+
containers, err := api.cl.List(ctx)
162+
if err != nil {
163+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
164+
Message: "Could not list containers",
165+
Detail: err.Error(),
166+
})
167+
return
168+
}
169+
170+
containerIdx := slices.IndexFunc(containers.Containers, func(c codersdk.WorkspaceAgentContainer) bool {
171+
return c.Match(id)
172+
})
173+
if containerIdx == -1 {
174+
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
175+
Message: "Container not found",
176+
Detail: "Container ID or name not found in the list of containers.",
177+
})
178+
return
179+
}
180+
181+
container := containers.Containers[containerIdx]
182+
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
183+
configPath := container.Labels[DevcontainerConfigFileLabel]
184+
185+
// Workspace folder is required to recreate a container, we don't verify
186+
// the config path here because it's optional.
187+
if workspaceFolder == "" {
188+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
189+
Message: "Missing workspace folder label",
190+
Detail: "The workspace folder label is required to recreate a devcontainer.",
191+
})
192+
return
193+
}
194+
195+
_, err = api.dccli.Up(ctx, workspaceFolder, configPath, WithRemoveExistingContainer())
196+
if err != nil {
197+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
198+
Message: "Could not recreate devcontainer",
199+
Detail: err.Error(),
200+
})
201+
return
202+
}
203+
204+
w.WriteHeader(http.StatusNoContent)
205+
}
+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package agentcontainers
2+
3+
import (
4+
"math/rand"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/google/uuid"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"go.uber.org/mock/gomock"
13+
14+
"cdr.dev/slog"
15+
"cdr.dev/slog/sloggers/slogtest"
16+
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
17+
"github.com/coder/coder/v2/codersdk"
18+
"github.com/coder/coder/v2/testutil"
19+
"github.com/coder/quartz"
20+
)
21+
22+
func TestAPI(t *testing.T) {
23+
t.Parallel()
24+
25+
// List tests the API.getContainers method using a mock
26+
// implementation. It specifically tests caching behavior.
27+
t.Run("List", func(t *testing.T) {
28+
t.Parallel()
29+
30+
fakeCt := fakeContainer(t)
31+
fakeCt2 := fakeContainer(t)
32+
makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse {
33+
return codersdk.WorkspaceAgentListContainersResponse{Containers: cts}
34+
}
35+
36+
// Each test case is called multiple times to ensure idempotency
37+
for _, tc := range []struct {
38+
name string
39+
// data to be stored in the handler
40+
cacheData codersdk.WorkspaceAgentListContainersResponse
41+
// duration of cache
42+
cacheDur time.Duration
43+
// relative age of the cached data
44+
cacheAge time.Duration
45+
// function to set up expectations for the mock
46+
setupMock func(*acmock.MockLister)
47+
// expected result
48+
expected codersdk.WorkspaceAgentListContainersResponse
49+
// expected error
50+
expectedErr string
51+
}{
52+
{
53+
name: "no cache",
54+
setupMock: func(mcl *acmock.MockLister) {
55+
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
56+
},
57+
expected: makeResponse(fakeCt),
58+
},
59+
{
60+
name: "no data",
61+
cacheData: makeResponse(),
62+
cacheAge: 2 * time.Second,
63+
cacheDur: time.Second,
64+
setupMock: func(mcl *acmock.MockLister) {
65+
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
66+
},
67+
expected: makeResponse(fakeCt),
68+
},
69+
{
70+
name: "cached data",
71+
cacheAge: time.Second,
72+
cacheData: makeResponse(fakeCt),
73+
cacheDur: 2 * time.Second,
74+
expected: makeResponse(fakeCt),
75+
},
76+
{
77+
name: "lister error",
78+
setupMock: func(mcl *acmock.MockLister) {
79+
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes()
80+
},
81+
expectedErr: assert.AnError.Error(),
82+
},
83+
{
84+
name: "stale cache",
85+
cacheAge: 2 * time.Second,
86+
cacheData: makeResponse(fakeCt),
87+
cacheDur: time.Second,
88+
setupMock: func(mcl *acmock.MockLister) {
89+
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes()
90+
},
91+
expected: makeResponse(fakeCt2),
92+
},
93+
} {
94+
tc := tc
95+
t.Run(tc.name, func(t *testing.T) {
96+
t.Parallel()
97+
var (
98+
ctx = testutil.Context(t, testutil.WaitShort)
99+
clk = quartz.NewMock(t)
100+
ctrl = gomock.NewController(t)
101+
mockLister = acmock.NewMockLister(ctrl)
102+
now = time.Now().UTC()
103+
logger = slogtest.Make(t, nil).Leveled(slog.LevelDebug)
104+
api = NewAPI(logger, WithLister(mockLister))
105+
)
106+
api.cacheDuration = tc.cacheDur
107+
api.clock = clk
108+
api.containers = tc.cacheData
109+
if tc.cacheAge != 0 {
110+
api.mtime = now.Add(-tc.cacheAge)
111+
}
112+
if tc.setupMock != nil {
113+
tc.setupMock(mockLister)
114+
}
115+
116+
clk.Set(now).MustWait(ctx)
117+
118+
// Repeat the test to ensure idempotency
119+
for i := 0; i < 2; i++ {
120+
actual, err := api.getContainers(ctx)
121+
if tc.expectedErr != "" {
122+
require.Empty(t, actual, "expected no data (attempt %d)", i)
123+
require.ErrorContains(t, err, tc.expectedErr, "expected error (attempt %d)", i)
124+
} else {
125+
require.NoError(t, err, "expected no error (attempt %d)", i)
126+
require.Equal(t, tc.expected, actual, "expected containers to be equal (attempt %d)", i)
127+
}
128+
}
129+
})
130+
}
131+
})
132+
}
133+
134+
func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer {
135+
t.Helper()
136+
ct := codersdk.WorkspaceAgentContainer{
137+
CreatedAt: time.Now().UTC(),
138+
ID: uuid.New().String(),
139+
FriendlyName: testutil.GetRandomName(t),
140+
Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0],
141+
Labels: map[string]string{
142+
testutil.GetRandomName(t): testutil.GetRandomName(t),
143+
},
144+
Running: true,
145+
Ports: []codersdk.WorkspaceAgentContainerPort{
146+
{
147+
Network: "tcp",
148+
Port: testutil.RandomPortNoListen(t),
149+
HostPort: testutil.RandomPortNoListen(t),
150+
//nolint:gosec // this is a test
151+
HostIP: []string{"127.0.0.1", "[::1]", "localhost", "0.0.0.0", "[::]", testutil.GetRandomName(t)}[rand.Intn(6)],
152+
},
153+
},
154+
Status: testutil.MustRandString(t, 10),
155+
Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)},
156+
}
157+
for _, m := range mut {
158+
m(&ct)
159+
}
160+
return ct
161+
}

0 commit comments

Comments
 (0)