mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat(agent/agentcontainers): add file watcher and dirty status (#17573)
Fixes coder/internal#479 Fixes coder/internal#480
This commit is contained in:
committed by
GitHub
parent
b6146dfe8a
commit
268a50c193
@ -10,11 +10,13 @@ import (
|
||||
"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"
|
||||
@ -30,6 +32,12 @@ const (
|
||||
// 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
|
||||
cl Lister
|
||||
dccli DevcontainerCLI
|
||||
@ -37,11 +45,12 @@ type API struct {
|
||||
|
||||
// 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.
|
||||
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.
|
||||
@ -55,6 +64,16 @@ func WithLister(cl Lister) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -76,14 +95,29 @@ func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer) Opti
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
clock: quartz.NewReal(),
|
||||
cacheDuration: defaultGetContainersCacheDuration,
|
||||
lockCh: make(chan struct{}, 1),
|
||||
devcontainerNames: make(map[string]struct{}),
|
||||
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
logger: logger,
|
||||
clock: quartz.NewReal(),
|
||||
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)
|
||||
@ -92,12 +126,64 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
|
||||
api.cl = &DockerCLILister{}
|
||||
}
|
||||
if api.dccli == nil {
|
||||
api.dccli = NewDevcontainerCLI(logger, agentexec.DefaultExecer)
|
||||
api.dccli = NewDevcontainerCLI(logger.Named("devcontainer-cli"), agentexec.DefaultExecer)
|
||||
}
|
||||
if api.watcher == nil {
|
||||
api.watcher = watcher.NewNoop()
|
||||
}
|
||||
|
||||
// Make sure we watch the devcontainer config files for changes.
|
||||
for _, devcontainer := range api.knownDevcontainers {
|
||||
if devcontainer.ConfigPath != "" {
|
||||
if err := api.watcher.Add(devcontainer.ConfigPath); err != nil {
|
||||
api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", devcontainer.ConfigPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go api.start()
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
func (api *API) start() {
|
||||
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()
|
||||
@ -143,12 +229,12 @@ func copyListContainersResponse(resp codersdk.WorkspaceAgentListContainersRespon
|
||||
|
||||
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
|
||||
}()
|
||||
defer func() { <-api.lockCh }()
|
||||
}
|
||||
|
||||
now := api.clock.Now()
|
||||
@ -165,51 +251,99 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC
|
||||
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]
|
||||
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
|
||||
}
|
||||
configFile := container.Labels[DevcontainerConfigFileLabel]
|
||||
|
||||
// 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
|
||||
}
|
||||
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.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,
|
||||
})
|
||||
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
|
||||
@ -271,6 +405,29 @@ func (api *API) handleRecreate(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -289,6 +446,8 @@ func (api *API) handleListDevcontainers(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-api.ctx.Done():
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case api.lockCh <- struct{}{}:
|
||||
@ -309,3 +468,46 @@ func (api *API) handleListDevcontainers(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
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
|
||||
}
|
||||
|
Reference in New Issue
Block a user