mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
There were too many ways to configure the agentcontainers API resulting in inconsistent behavior or features not being enabled. This refactor introduces a control flag for enabling or disabling the containers API. When disabled, all implementations are no-op and explicit endpoint behaviors are defined. When enabled, concrete implementations are used by default but can be overridden by passing options.
541 lines
17 KiB
Go
541 lines
17 KiB
Go
package agentcontainers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
|
"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 {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
logger slog.Logger
|
|
watcher watcher.Watcher
|
|
|
|
cacheDuration time.Duration
|
|
execer agentexec.Execer
|
|
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.
|
|
configFileModifiedTimes map[string]time.Time // Track when config files were last modified.
|
|
}
|
|
|
|
// Option is a functional option for API.
|
|
type Option func(*API)
|
|
|
|
// WithClock sets the quartz.Clock implementation to use.
|
|
// This is primarily used for testing to control time.
|
|
func WithClock(clock quartz.Clock) Option {
|
|
return func(api *API) {
|
|
api.clock = clock
|
|
}
|
|
}
|
|
|
|
// WithExecer sets the agentexec.Execer implementation to use.
|
|
func WithExecer(execer agentexec.Execer) Option {
|
|
return func(api *API) {
|
|
api.execer = execer
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// WithDevcontainerCLI sets the DevcontainerCLI implementation to use.
|
|
// This can be used in tests to modify @devcontainer/cli behavior.
|
|
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{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithWatcher sets the file watcher implementation to use. By default a
|
|
// noop watcher is used. This can be used in tests to modify the watcher
|
|
// behavior or to use an actual file watcher (e.g. fsnotify).
|
|
func WithWatcher(w watcher.Watcher) Option {
|
|
return func(api *API) {
|
|
api.watcher = w
|
|
}
|
|
}
|
|
|
|
// NewAPI returns a new API with the given options applied.
|
|
func NewAPI(logger slog.Logger, options ...Option) *API {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
api := &API{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
done: make(chan struct{}),
|
|
logger: logger,
|
|
clock: quartz.NewReal(),
|
|
execer: agentexec.DefaultExecer,
|
|
cacheDuration: defaultGetContainersCacheDuration,
|
|
lockCh: make(chan struct{}, 1),
|
|
devcontainerNames: make(map[string]struct{}),
|
|
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{},
|
|
configFileModifiedTimes: make(map[string]time.Time),
|
|
}
|
|
for _, opt := range options {
|
|
opt(api)
|
|
}
|
|
if api.cl == nil {
|
|
api.cl = NewDocker(api.execer)
|
|
}
|
|
if api.dccli == nil {
|
|
api.dccli = NewDevcontainerCLI(logger.Named("devcontainer-cli"), api.execer)
|
|
}
|
|
if api.watcher == nil {
|
|
var err error
|
|
api.watcher, err = watcher.NewFSNotify()
|
|
if err != nil {
|
|
logger.Error(ctx, "create file watcher service failed", slog.Error(err))
|
|
api.watcher = watcher.NewNoop()
|
|
}
|
|
}
|
|
|
|
go api.loop()
|
|
|
|
return api
|
|
}
|
|
|
|
// SignalReady signals the API that we are ready to begin watching for
|
|
// file changes. This is used to prime the cache with the current list
|
|
// of containers and to start watching the devcontainer config files for
|
|
// changes. It should be called after the agent ready.
|
|
func (api *API) SignalReady() {
|
|
// Prime the cache with the current list of containers.
|
|
_, _ = api.cl.List(api.ctx)
|
|
|
|
// Make sure we watch the devcontainer config files for changes.
|
|
for _, devcontainer := range api.knownDevcontainers {
|
|
if devcontainer.ConfigPath == "" {
|
|
continue
|
|
}
|
|
|
|
if err := api.watcher.Add(devcontainer.ConfigPath); err != nil {
|
|
api.logger.Error(api.ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", devcontainer.ConfigPath))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (api *API) loop() {
|
|
defer close(api.done)
|
|
|
|
for {
|
|
event, err := api.watcher.Next(api.ctx)
|
|
if err != nil {
|
|
if errors.Is(err, watcher.ErrClosed) {
|
|
api.logger.Debug(api.ctx, "watcher closed")
|
|
return
|
|
}
|
|
if api.ctx.Err() != nil {
|
|
api.logger.Debug(api.ctx, "api context canceled")
|
|
return
|
|
}
|
|
api.logger.Error(api.ctx, "watcher error waiting for next event", slog.Error(err))
|
|
continue
|
|
}
|
|
if event == nil {
|
|
continue
|
|
}
|
|
|
|
now := api.clock.Now()
|
|
switch {
|
|
case event.Has(fsnotify.Create | fsnotify.Write):
|
|
api.logger.Debug(api.ctx, "devcontainer config file changed", slog.F("file", event.Name))
|
|
api.markDevcontainerDirty(event.Name, now)
|
|
case event.Has(fsnotify.Remove):
|
|
api.logger.Debug(api.ctx, "devcontainer config file removed", slog.F("file", event.Name))
|
|
api.markDevcontainerDirty(event.Name, now)
|
|
case event.Has(fsnotify.Rename):
|
|
api.logger.Debug(api.ctx, "devcontainer config file renamed", slog.F("file", event.Name))
|
|
api.markDevcontainerDirty(event.Name, now)
|
|
default:
|
|
api.logger.Debug(api.ctx, "devcontainer config file event ignored", slog.F("file", event.Name), slog.F("event", event))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 <-api.ctx.Done():
|
|
return codersdk.WorkspaceAgentListContainersResponse{}, api.ctx.Err()
|
|
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
|
|
|
|
dirtyStates := make(map[string]bool)
|
|
// Reset all known devcontainers to not running.
|
|
for i := range api.knownDevcontainers {
|
|
api.knownDevcontainers[i].Running = false
|
|
api.knownDevcontainers[i].Container = nil
|
|
|
|
// Preserve the dirty state and store in map for lookup.
|
|
dirtyStates[api.knownDevcontainers[i].WorkspaceFolder] = api.knownDevcontainers[i].Dirty
|
|
}
|
|
|
|
// Check if the container is running and update the known devcontainers.
|
|
for _, container := range updated.Containers {
|
|
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
|
configFile := container.Labels[DevcontainerConfigFileLabel]
|
|
|
|
if workspaceFolder == "" {
|
|
continue
|
|
}
|
|
|
|
// 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 configFile != "" && api.knownDevcontainers[knownIndex].ConfigPath == "" {
|
|
api.knownDevcontainers[knownIndex].ConfigPath = configFile
|
|
if err := api.watcher.Add(configFile); err != nil {
|
|
api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", configFile))
|
|
}
|
|
}
|
|
api.knownDevcontainers[knownIndex].Running = container.Running
|
|
api.knownDevcontainers[knownIndex].Container = &container
|
|
|
|
// Check if this container was created after the config
|
|
// file was modified.
|
|
if configFile != "" && api.knownDevcontainers[knownIndex].Dirty {
|
|
lastModified, hasModTime := api.configFileModifiedTimes[configFile]
|
|
if hasModTime && container.CreatedAt.After(lastModified) {
|
|
api.logger.Info(ctx, "clearing dirty flag for container created after config modification",
|
|
slog.F("container", container.ID),
|
|
slog.F("created_at", container.CreatedAt),
|
|
slog.F("config_modified_at", lastModified),
|
|
slog.F("file", configFile),
|
|
)
|
|
api.knownDevcontainers[knownIndex].Dirty = false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// NOTE(mafredri): This name impl. may change to accommodate devcontainer agents RFC.
|
|
// 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{}{}
|
|
if configFile != "" {
|
|
if err := api.watcher.Add(configFile); err != nil {
|
|
api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", configFile))
|
|
}
|
|
}
|
|
|
|
dirty := dirtyStates[workspaceFolder]
|
|
if dirty {
|
|
lastModified, hasModTime := api.configFileModifiedTimes[configFile]
|
|
if hasModTime && container.CreatedAt.After(lastModified) {
|
|
api.logger.Info(ctx, "new container created after config modification, not marking as dirty",
|
|
slog.F("container", container.ID),
|
|
slog.F("created_at", container.CreatedAt),
|
|
slog.F("config_modified_at", lastModified),
|
|
slog.F("file", configFile),
|
|
)
|
|
dirty = false
|
|
}
|
|
}
|
|
|
|
api.knownDevcontainers = append(api.knownDevcontainers, codersdk.WorkspaceAgentDevcontainer{
|
|
ID: uuid.New(),
|
|
Name: name,
|
|
WorkspaceFolder: workspaceFolder,
|
|
ConfigPath: configFile,
|
|
Running: container.Running,
|
|
Dirty: dirty,
|
|
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
|
|
}
|
|
|
|
// TODO(mafredri): Temporarily handle clearing the dirty state after
|
|
// recreation, later on this should be handled by a "container watcher".
|
|
select {
|
|
case <-api.ctx.Done():
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
case api.lockCh <- struct{}{}:
|
|
defer func() { <-api.lockCh }()
|
|
}
|
|
for i := range api.knownDevcontainers {
|
|
if api.knownDevcontainers[i].WorkspaceFolder == workspaceFolder {
|
|
if api.knownDevcontainers[i].Dirty {
|
|
api.logger.Info(ctx, "clearing dirty flag after recreation",
|
|
slog.F("workspace_folder", workspaceFolder),
|
|
slog.F("name", api.knownDevcontainers[i].Name),
|
|
)
|
|
api.knownDevcontainers[i].Dirty = false
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
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 <-api.ctx.Done():
|
|
return
|
|
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)
|
|
}
|
|
|
|
// markDevcontainerDirty finds the devcontainer with the given config file path
|
|
// and marks it as dirty. It acquires the lock before modifying the state.
|
|
func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) {
|
|
select {
|
|
case <-api.ctx.Done():
|
|
return
|
|
case api.lockCh <- struct{}{}:
|
|
defer func() { <-api.lockCh }()
|
|
}
|
|
|
|
// Record the timestamp of when this configuration file was modified.
|
|
api.configFileModifiedTimes[configPath] = modifiedAt
|
|
|
|
for i := range api.knownDevcontainers {
|
|
if api.knownDevcontainers[i].ConfigPath != configPath {
|
|
continue
|
|
}
|
|
|
|
// TODO(mafredri): Simplistic mark for now, we should check if the
|
|
// container is running and if the config file was modified after
|
|
// the container was created.
|
|
if !api.knownDevcontainers[i].Dirty {
|
|
api.logger.Info(api.ctx, "marking devcontainer as dirty",
|
|
slog.F("file", configPath),
|
|
slog.F("name", api.knownDevcontainers[i].Name),
|
|
slog.F("workspace_folder", api.knownDevcontainers[i].WorkspaceFolder),
|
|
slog.F("modified_at", modifiedAt),
|
|
)
|
|
api.knownDevcontainers[i].Dirty = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (api *API) Close() error {
|
|
api.cancel()
|
|
<-api.done
|
|
err := api.watcher.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|