mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
This change allows listing both predefined and runtime-detected devcontainers, as well as showing whether or not the devcontainer is running and which container represents it. Fixes coder/internal#478
312 lines
9.3 KiB
Go
312 lines
9.3 KiB
Go
package agentcontainers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/v2/agent/agentexec"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
const (
|
|
defaultGetContainersCacheDuration = 10 * time.Second
|
|
dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST"
|
|
getContainersTimeout = 5 * time.Second
|
|
)
|
|
|
|
// API is responsible for container-related operations in the agent.
|
|
// It provides methods to list and manage containers.
|
|
type API struct {
|
|
cacheDuration time.Duration
|
|
cl Lister
|
|
dccli DevcontainerCLI
|
|
clock quartz.Clock
|
|
|
|
// lockCh protects the below fields. We use a channel instead of a
|
|
// mutex so we can handle cancellation properly.
|
|
lockCh chan struct{}
|
|
containers codersdk.WorkspaceAgentListContainersResponse
|
|
mtime time.Time
|
|
devcontainerNames map[string]struct{} // Track devcontainer names to avoid duplicates.
|
|
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer // Track predefined and runtime-detected devcontainers.
|
|
}
|
|
|
|
// Option is a functional option for API.
|
|
type Option func(*API)
|
|
|
|
// WithLister sets the agentcontainers.Lister implementation to use.
|
|
// The default implementation uses the Docker CLI to list containers.
|
|
func WithLister(cl Lister) Option {
|
|
return func(api *API) {
|
|
api.cl = cl
|
|
}
|
|
}
|
|
|
|
func WithDevcontainerCLI(dccli DevcontainerCLI) Option {
|
|
return func(api *API) {
|
|
api.dccli = dccli
|
|
}
|
|
}
|
|
|
|
// WithDevcontainers sets the known devcontainers for the API. This
|
|
// allows the API to be aware of devcontainers defined in the workspace
|
|
// agent manifest.
|
|
func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer) Option {
|
|
return func(api *API) {
|
|
if len(devcontainers) > 0 {
|
|
api.knownDevcontainers = slices.Clone(devcontainers)
|
|
api.devcontainerNames = make(map[string]struct{}, len(devcontainers))
|
|
for _, devcontainer := range devcontainers {
|
|
api.devcontainerNames[devcontainer.Name] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewAPI returns a new API with the given options applied.
|
|
func NewAPI(logger slog.Logger, options ...Option) *API {
|
|
api := &API{
|
|
clock: quartz.NewReal(),
|
|
cacheDuration: defaultGetContainersCacheDuration,
|
|
lockCh: make(chan struct{}, 1),
|
|
devcontainerNames: make(map[string]struct{}),
|
|
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{},
|
|
}
|
|
for _, opt := range options {
|
|
opt(api)
|
|
}
|
|
if api.cl == nil {
|
|
api.cl = &DockerCLILister{}
|
|
}
|
|
if api.dccli == nil {
|
|
api.dccli = NewDevcontainerCLI(logger, agentexec.DefaultExecer)
|
|
}
|
|
|
|
return api
|
|
}
|
|
|
|
// Routes returns the HTTP handler for container-related routes.
|
|
func (api *API) Routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Get("/", api.handleList)
|
|
r.Get("/devcontainers", api.handleListDevcontainers)
|
|
r.Post("/{id}/recreate", api.handleRecreate)
|
|
return r
|
|
}
|
|
|
|
// handleList handles the HTTP request to list containers.
|
|
func (api *API) handleList(rw http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-r.Context().Done():
|
|
// Client went away.
|
|
return
|
|
default:
|
|
ct, err := api.getContainers(r.Context())
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{
|
|
Message: "Could not get containers.",
|
|
Detail: "Took too long to list containers.",
|
|
})
|
|
return
|
|
}
|
|
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Could not get containers.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
|
|
}
|
|
}
|
|
|
|
func copyListContainersResponse(resp codersdk.WorkspaceAgentListContainersResponse) codersdk.WorkspaceAgentListContainersResponse {
|
|
return codersdk.WorkspaceAgentListContainersResponse{
|
|
Containers: slices.Clone(resp.Containers),
|
|
Warnings: slices.Clone(resp.Warnings),
|
|
}
|
|
}
|
|
|
|
func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
|
|
case api.lockCh <- struct{}{}:
|
|
defer func() {
|
|
<-api.lockCh
|
|
}()
|
|
}
|
|
|
|
now := api.clock.Now()
|
|
if now.Sub(api.mtime) < api.cacheDuration {
|
|
return copyListContainersResponse(api.containers), nil
|
|
}
|
|
|
|
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
|
|
defer timeoutCancel()
|
|
updated, err := api.cl.List(timeoutCtx)
|
|
if err != nil {
|
|
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
|
|
}
|
|
api.containers = updated
|
|
api.mtime = now
|
|
|
|
// Reset all known devcontainers to not running.
|
|
for i := range api.knownDevcontainers {
|
|
api.knownDevcontainers[i].Running = false
|
|
api.knownDevcontainers[i].Container = nil
|
|
}
|
|
|
|
// Check if the container is running and update the known devcontainers.
|
|
for _, container := range updated.Containers {
|
|
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
|
if workspaceFolder != "" {
|
|
// Check if this is already in our known list.
|
|
if knownIndex := slices.IndexFunc(api.knownDevcontainers, func(dc codersdk.WorkspaceAgentDevcontainer) bool {
|
|
return dc.WorkspaceFolder == workspaceFolder
|
|
}); knownIndex != -1 {
|
|
// Update existing entry with runtime information.
|
|
if api.knownDevcontainers[knownIndex].ConfigPath == "" {
|
|
api.knownDevcontainers[knownIndex].ConfigPath = container.Labels[DevcontainerConfigFileLabel]
|
|
}
|
|
api.knownDevcontainers[knownIndex].Running = container.Running
|
|
api.knownDevcontainers[knownIndex].Container = &container
|
|
continue
|
|
}
|
|
|
|
// If not in our known list, add as a runtime detected entry.
|
|
name := path.Base(workspaceFolder)
|
|
if _, ok := api.devcontainerNames[name]; ok {
|
|
// Try to find a unique name by appending a number.
|
|
for i := 2; ; i++ {
|
|
newName := fmt.Sprintf("%s-%d", name, i)
|
|
if _, ok := api.devcontainerNames[newName]; !ok {
|
|
name = newName
|
|
break
|
|
}
|
|
}
|
|
}
|
|
api.devcontainerNames[name] = struct{}{}
|
|
api.knownDevcontainers = append(api.knownDevcontainers, codersdk.WorkspaceAgentDevcontainer{
|
|
ID: uuid.New(),
|
|
Name: name,
|
|
WorkspaceFolder: workspaceFolder,
|
|
ConfigPath: container.Labels[DevcontainerConfigFileLabel],
|
|
Running: container.Running,
|
|
Container: &container,
|
|
})
|
|
}
|
|
}
|
|
|
|
return copyListContainersResponse(api.containers), nil
|
|
}
|
|
|
|
// handleRecreate handles the HTTP request to recreate a container.
|
|
func (api *API) handleRecreate(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
id := chi.URLParam(r, "id")
|
|
|
|
if id == "" {
|
|
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Missing container ID or name",
|
|
Detail: "Container ID or name is required to recreate a devcontainer.",
|
|
})
|
|
return
|
|
}
|
|
|
|
containers, err := api.getContainers(ctx)
|
|
if err != nil {
|
|
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Could not list containers",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
containerIdx := slices.IndexFunc(containers.Containers, func(c codersdk.WorkspaceAgentContainer) bool {
|
|
return c.Match(id)
|
|
})
|
|
if containerIdx == -1 {
|
|
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
|
|
Message: "Container not found",
|
|
Detail: "Container ID or name not found in the list of containers.",
|
|
})
|
|
return
|
|
}
|
|
|
|
container := containers.Containers[containerIdx]
|
|
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
|
configPath := container.Labels[DevcontainerConfigFileLabel]
|
|
|
|
// Workspace folder is required to recreate a container, we don't verify
|
|
// the config path here because it's optional.
|
|
if workspaceFolder == "" {
|
|
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Missing workspace folder label",
|
|
Detail: "The workspace folder label is required to recreate a devcontainer.",
|
|
})
|
|
return
|
|
}
|
|
|
|
_, err = api.dccli.Up(ctx, workspaceFolder, configPath, WithRemoveExistingContainer())
|
|
if err != nil {
|
|
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Could not recreate devcontainer",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleListDevcontainers handles the HTTP request to list known devcontainers.
|
|
func (api *API) handleListDevcontainers(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Run getContainers to detect the latest devcontainers and their state.
|
|
_, err := api.getContainers(ctx)
|
|
if err != nil {
|
|
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Could not list containers",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case api.lockCh <- struct{}{}:
|
|
}
|
|
devcontainers := slices.Clone(api.knownDevcontainers)
|
|
<-api.lockCh
|
|
|
|
slices.SortFunc(devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
|
|
if cmp := strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder); cmp != 0 {
|
|
return cmp
|
|
}
|
|
return strings.Compare(a.ConfigPath, b.ConfigPath)
|
|
})
|
|
|
|
response := codersdk.WorkspaceAgentDevcontainersResponse{
|
|
Devcontainers: devcontainers,
|
|
}
|
|
|
|
httpapi.Write(ctx, w, http.StatusOK, response)
|
|
}
|