diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 1dddcc709848e..71b5267f40fec 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -37,7 +37,7 @@ const ( // Destination path inside the container, we store it in a fixed location // under /.coder-agent/coder to avoid conflicts and avoid being shadowed // by tmpfs or other mounts. This assumes the container root filesystem is - // read-write, which seems sensible for dev containers. + // read-write, which seems sensible for devcontainers. coderPathInsideContainer = "/.coder-agent/coder" ) @@ -72,16 +72,17 @@ type API struct { configFileModifiedTimes map[string]time.Time // By config file path. recreateSuccessTimes map[string]time.Time // By workspace folder. recreateErrorTimes map[string]time.Time // By workspace folder. - injectedSubAgentProcs map[string]subAgentProcess // By container ID. + injectedSubAgentProcs map[string]subAgentProcess // By workspace folder. asyncWg sync.WaitGroup devcontainerLogSourceIDs map[string]uuid.UUID // By workspace folder. } type subAgentProcess struct { - agent SubAgent - ctx context.Context - stop context.CancelFunc + agent SubAgent + containerID string + ctx context.Context + stop context.CancelFunc } // Option is a functional option for API. @@ -129,7 +130,7 @@ func WithDevcontainerCLI(dccli DevcontainerCLI) Option { } // WithSubAgentClient sets the SubAgentClient implementation to use. -// This is used to list, create and delete Dev Container agents. +// This is used to list, create, and delete devcontainer agents. func WithSubAgentClient(client SubAgentClient) Option { return func(api *API) { api.subAgentClient = client @@ -403,8 +404,9 @@ func (api *API) Routes() http.Handler { r.Use(ensureInitialUpdateDoneMW) r.Get("/", api.handleList) + // TODO(mafredri): Simplify this route as the previous /devcontainers + // /-route was dropped. We can drop the /devcontainers prefix here too. r.Route("/devcontainers", func(r chi.Router) { - r.Get("/", api.handleDevcontainersList) r.Post("/container/{container}/recreate", api.handleDevcontainerRecreate) }) @@ -486,8 +488,6 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code // Check if the container is running and update the known devcontainers. for i := range updated.Containers { container := &updated.Containers[i] // Grab a reference to the container to allow mutating it. - container.DevcontainerStatus = "" // Reset the status for the container (updated later). - container.DevcontainerDirty = false // Reset dirty state for the container (updated later). workspaceFolder := container.Labels[DevcontainerLocalFolderLabel] configFile := container.Labels[DevcontainerConfigFileLabel] @@ -513,10 +513,10 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code // Verbose debug logging is fine here since typically filters // are only used in development or testing environments. if !ok { - logger.Debug(ctx, "container does not match include filter, ignoring dev container", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) + logger.Debug(ctx, "container does not match include filter, ignoring devcontainer", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) continue } - logger.Debug(ctx, "container matches include filter, processing dev container", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) + logger.Debug(ctx, "container matches include filter, processing devcontainer", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) } if dc, ok := api.knownDevcontainers[workspaceFolder]; ok { @@ -564,12 +564,10 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code if !api.devcontainerNames[dc.Name] { // If the devcontainer name wasn't set via terraform, we // use the containers friendly name as a fallback which - // will keep changing as the dev container is recreated. + // will keep changing as the devcontainer is recreated. // TODO(mafredri): Parse the container label (i.e. devcontainer.json) for customization. dc.Name = safeFriendlyName(dc.Container.FriendlyName) } - dc.Container.DevcontainerStatus = dc.Status - dc.Container.DevcontainerDirty = dc.Dirty } switch { @@ -584,16 +582,14 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code if dc.Container.Running { dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning } - dc.Container.DevcontainerStatus = dc.Status dc.Dirty = false if lastModified, hasModTime := api.configFileModifiedTimes[dc.ConfigPath]; hasModTime && dc.Container.CreatedAt.Before(lastModified) { dc.Dirty = true } - dc.Container.DevcontainerDirty = dc.Dirty - if _, injected := api.injectedSubAgentProcs[dc.Container.ID]; !injected && dc.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning { - err := api.injectSubAgentIntoContainerLocked(ctx, dc) + if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning { + err := api.maybeInjectSubAgentIntoContainerLocked(ctx, dc) if err != nil { logger.Error(ctx, "inject subagent into container failed", slog.Error(err)) } @@ -661,9 +657,32 @@ func (api *API) getContainers() (codersdk.WorkspaceAgentListContainersResponse, if api.containersErr != nil { return codersdk.WorkspaceAgentListContainersResponse{}, api.containersErr } + + var devcontainers []codersdk.WorkspaceAgentDevcontainer + if len(api.knownDevcontainers) > 0 { + devcontainers = make([]codersdk.WorkspaceAgentDevcontainer, 0, len(api.knownDevcontainers)) + for _, dc := range api.knownDevcontainers { + // Include the agent if it's been created (we're iterating over + // copies, so mutating is fine). + if proc := api.injectedSubAgentProcs[dc.WorkspaceFolder]; proc.agent.ID != uuid.Nil && dc.Container != nil && proc.containerID == dc.Container.ID { + dc.Agent = &codersdk.WorkspaceAgentDevcontainerAgent{ + ID: proc.agent.ID, + Name: proc.agent.Name, + Directory: proc.agent.Directory, + } + } + + devcontainers = append(devcontainers, dc) + } + slices.SortFunc(devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int { + return strings.Compare(a.Name, b.Name) + }) + } + return codersdk.WorkspaceAgentListContainersResponse{ - Containers: slices.Clone(api.containers.Containers), - Warnings: slices.Clone(api.containers.Warnings), + Devcontainers: devcontainers, + Containers: slices.Clone(api.containers.Containers), + Warnings: slices.Clone(api.containers.Warnings), }, nil } @@ -740,9 +759,7 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques // Update the status so that we don't try to recreate the // devcontainer multiple times in parallel. dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting - if dc.Container != nil { - dc.Container.DevcontainerStatus = dc.Status - } + dc.Container = nil api.knownDevcontainers[dc.WorkspaceFolder] = dc api.asyncWg.Add(1) go api.recreateDevcontainer(dc, configPath) @@ -815,9 +832,6 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con api.mu.Lock() dc = api.knownDevcontainers[dc.WorkspaceFolder] dc.Status = codersdk.WorkspaceAgentDevcontainerStatusError - if dc.Container != nil { - dc.Container.DevcontainerStatus = dc.Status - } api.knownDevcontainers[dc.WorkspaceFolder] = dc api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "errorTimes") api.mu.Unlock() @@ -838,7 +852,6 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con if dc.Container.Running { dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning } - dc.Container.DevcontainerStatus = dc.Status } dc.Dirty = false api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "successTimes") @@ -852,39 +865,6 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con } } -// handleDevcontainersList handles the HTTP request to list known devcontainers. -func (api *API) handleDevcontainersList(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - api.mu.RLock() - err := api.containersErr - devcontainers := make([]codersdk.WorkspaceAgentDevcontainer, 0, len(api.knownDevcontainers)) - for _, dc := range api.knownDevcontainers { - devcontainers = append(devcontainers, dc) - } - api.mu.RUnlock() - if err != nil { - httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ - Message: "Could not list containers", - Detail: err.Error(), - }) - return - } - - 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) { @@ -914,10 +894,6 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) { logger.Info(api.ctx, "marking devcontainer as dirty") dc.Dirty = true } - if dc.Container != nil && !dc.Container.DevcontainerDirty { - logger.Info(api.ctx, "marking devcontainer container as dirty") - dc.Container.DevcontainerDirty = true - } api.knownDevcontainers[dc.WorkspaceFolder] = dc } @@ -964,13 +940,14 @@ func (api *API) cleanupSubAgents(ctx context.Context) error { return nil } -// injectSubAgentIntoContainerLocked injects a subagent into a dev +// maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev // container and starts the subagent process. This method assumes that -// api.mu is held. +// api.mu is held. This method is idempotent and will not re-inject the +// subagent if it is already/still running in the container. // // This method uses an internal timeout to prevent blocking indefinitely // if something goes wrong with the injection. -func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer) (err error) { +func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer) (err error) { ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout) defer cancel() @@ -979,17 +956,44 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders return xerrors.New("container is nil, cannot inject subagent") } - // Skip if subagent already exists for this container. - if _, injected := api.injectedSubAgentProcs[container.ID]; injected || api.closed { - return nil - } + logger := api.logger.With( + slog.F("devcontainer_id", dc.ID), + slog.F("devcontainer_name", dc.Name), + slog.F("workspace_folder", dc.WorkspaceFolder), + slog.F("config_path", dc.ConfigPath), + slog.F("container_id", container.ID), + slog.F("container_name", container.FriendlyName), + ) - // Mark subagent as being injected immediately with a placeholder. - subAgent := subAgentProcess{ - ctx: context.Background(), - stop: func() {}, + // Check if subagent already exists for this devcontainer. + recreateSubAgent := false + proc, injected := api.injectedSubAgentProcs[dc.WorkspaceFolder] + if injected { + if proc.containerID == container.ID && proc.ctx.Err() == nil { + // Same container and running, no need to reinject. + return nil + } + + if proc.containerID != container.ID { + // Always recreate the subagent if the container ID changed + // for now, in the future we can inspect e.g. if coder_apps + // remain the same and avoid unnecessary recreation. + logger.Debug(ctx, "container ID changed, injecting subagent into new container", + slog.F("old_container_id", proc.containerID), + ) + recreateSubAgent = true + } + + // Container ID changed or the subagent process is not running, + // stop the existing subagent context to replace it. + proc.stop() } - api.injectedSubAgentProcs[container.ID] = subAgent + + // Prepare the subAgentProcess to be used when running the subagent. + // We use api.ctx here to ensure that the process keeps running + // after this method returns. + proc.ctx, proc.stop = context.WithCancel(api.ctx) + api.injectedSubAgentProcs[dc.WorkspaceFolder] = proc // This is used to track the goroutine that will run the subagent // process inside the container. It will be decremented when the @@ -1001,12 +1005,13 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders // Clean up if injection fails. defer func() { if !ranSubAgent { + proc.stop() + if !api.closed { + // Ensure sure state modifications are reflected. + api.injectedSubAgentProcs[dc.WorkspaceFolder] = proc + } api.asyncWg.Done() } - if err != nil { - // Mutex is held (defer re-lock). - delete(api.injectedSubAgentProcs, container.ID) - } }() // Unlock the mutex to allow other operations while we @@ -1014,13 +1019,6 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders api.mu.Unlock() defer api.mu.Lock() // Re-lock. - logger := api.logger.With( - slog.F("devcontainer_id", dc.ID), - slog.F("devcontainer_name", dc.Name), - slog.F("workspace_folder", dc.WorkspaceFolder), - slog.F("config_path", dc.ConfigPath), - ) - arch, err := api.ccli.DetectArchitecture(ctx, container.ID) if err != nil { return xerrors.Errorf("detect architecture: %w", err) @@ -1035,7 +1033,8 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders if arch != hostArch { logger.Warn(ctx, "skipping subagent injection for unsupported architecture", slog.F("container_arch", arch), - slog.F("host_arch", hostArch)) + slog.F("host_arch", hostArch), + ) return nil } agentBinaryPath, err := os.Executable() @@ -1095,59 +1094,91 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders directory := strings.TrimSpace(pwdBuf.String()) if directory == "" { logger.Warn(ctx, "detected workspace folder is empty, using default workspace folder", - slog.F("default_workspace_folder", DevcontainerDefaultContainerWorkspaceFolder)) + slog.F("default_workspace_folder", DevcontainerDefaultContainerWorkspaceFolder), + ) directory = DevcontainerDefaultContainerWorkspaceFolder } - displayAppsMap := map[codersdk.DisplayApp]bool{ - // NOTE(DanielleMaywood): - // We use the same defaults here as set in terraform-provider-coder. - // https://github.com/coder/terraform-provider-coder/blob/c1c33f6d556532e75662c0ca373ed8fdea220eb5/provider/agent.go#L38-L51 - codersdk.DisplayAppVSCodeDesktop: true, - codersdk.DisplayAppVSCodeInsiders: false, - codersdk.DisplayAppWebTerminal: true, - codersdk.DisplayAppSSH: true, - codersdk.DisplayAppPortForward: true, - } + if proc.agent.ID != uuid.Nil && recreateSubAgent { + logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID)) + err = api.subAgentClient.Delete(ctx, proc.agent.ID) + if err != nil { + return xerrors.Errorf("delete existing subagent failed: %w", err) + } + proc.agent = SubAgent{} + } + if proc.agent.ID == uuid.Nil { + displayAppsMap := map[codersdk.DisplayApp]bool{ + // NOTE(DanielleMaywood): + // We use the same defaults here as set in terraform-provider-coder. + // https://github.com/coder/terraform-provider-coder/blob/c1c33f6d556532e75662c0ca373ed8fdea220eb5/provider/agent.go#L38-L51 + codersdk.DisplayAppVSCodeDesktop: true, + codersdk.DisplayAppVSCodeInsiders: false, + codersdk.DisplayAppWebTerminal: true, + codersdk.DisplayAppSSH: true, + codersdk.DisplayAppPortForward: true, + } - if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { - api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) - } else { - coderCustomization := config.MergedConfiguration.Customizations.Coder + if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { + api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) + } else { + coderCustomization := config.MergedConfiguration.Customizations.Coder - for _, customization := range coderCustomization { - for app, enabled := range customization.DisplayApps { - displayAppsMap[app] = enabled + for _, customization := range coderCustomization { + for app, enabled := range customization.DisplayApps { + displayAppsMap[app] = enabled + } } } - } - displayApps := make([]codersdk.DisplayApp, 0, len(displayAppsMap)) - for app, enabled := range displayAppsMap { - if enabled { - displayApps = append(displayApps, app) + displayApps := make([]codersdk.DisplayApp, 0, len(displayAppsMap)) + for app, enabled := range displayAppsMap { + if enabled { + displayApps = append(displayApps, app) + } } - } - // The preparation of the subagent is done, now we can create the - // subagent record in the database to receive the auth token. - createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{ - Name: dc.Name, - Directory: directory, - OperatingSystem: "linux", // Assuming Linux for dev containers. - Architecture: arch, - DisplayApps: displayApps, - }) - if err != nil { - return xerrors.Errorf("create agent: %w", err) + logger.Debug(ctx, "creating new subagent", + slog.F("directory", directory), + slog.F("display_apps", displayApps), + ) + + // Create new subagent record in the database to receive the auth token. + proc.agent, err = api.subAgentClient.Create(ctx, SubAgent{ + Name: dc.Name, + Directory: directory, + OperatingSystem: "linux", // Assuming Linux for devcontainers. + Architecture: arch, + DisplayApps: displayApps, + }) + if err != nil { + return xerrors.Errorf("create subagent failed: %w", err) + } + + logger.Info(ctx, "created new subagent", slog.F("agent_id", proc.agent.ID)) } - logger.Info(ctx, "created subagent record", slog.F("agent_id", createdAgent.ID)) + api.mu.Lock() // Re-lock to update the agent. + defer api.mu.Unlock() + if api.closed { + deleteCtx, deleteCancel := context.WithTimeout(context.Background(), defaultOperationTimeout) + defer deleteCancel() + err := api.subAgentClient.Delete(deleteCtx, proc.agent.ID) + if err != nil { + return xerrors.Errorf("delete existing subagent failed after API closed: %w", err) + } + return nil + } + // If we got this far, we should update the container ID to make + // sure we don't retry. If we update it too soon we may end up + // using an old subagent if e.g. delete failed previously. + proc.containerID = container.ID + api.injectedSubAgentProcs[dc.WorkspaceFolder] = proc // Start the subagent in the container in a new goroutine to avoid // blocking. Note that we pass the api.ctx to the subagent process // so that it isn't affected by the timeout. - go api.runSubAgentInContainer(api.ctx, dc, createdAgent, coderPathInsideContainer) + go api.runSubAgentInContainer(api.ctx, logger, dc, proc, coderPathInsideContainer) ranSubAgent = true return nil @@ -1157,59 +1188,26 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders // container. The api.asyncWg must be incremented before calling this // function, and it will be decremented when the subagent process // completes or if an error occurs. -func (api *API) runSubAgentInContainer(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer, agent SubAgent, agentPath string) { +func (api *API) runSubAgentInContainer(ctx context.Context, logger slog.Logger, dc codersdk.WorkspaceAgentDevcontainer, proc subAgentProcess, agentPath string) { container := dc.Container // Must not be nil. - logger := api.logger.With( - slog.F("container_name", container.FriendlyName), - slog.F("agent_id", agent.ID), + logger = logger.With( + slog.F("agent_id", proc.agent.ID), ) - agentCtx, agentStop := context.WithCancel(ctx) defer func() { - agentStop() - - // Best effort cleanup of the agent record after the process - // completes. Note that we use the background context here - // because the api.ctx will be canceled when the API is closed. - // This may delay shutdown of the agent by the given timeout. - deleteCtx, cancel := context.WithTimeout(context.Background(), defaultOperationTimeout) - defer cancel() - err := api.subAgentClient.Delete(deleteCtx, agent.ID) - if err != nil { - logger.Error(deleteCtx, "failed to delete agent record after process completion", slog.Error(err)) - } - - api.mu.Lock() - delete(api.injectedSubAgentProcs, container.ID) - api.mu.Unlock() - + proc.stop() logger.Debug(ctx, "agent process cleanup complete") api.asyncWg.Done() }() - api.mu.Lock() - if api.closed { - api.mu.Unlock() - // If the API is closed, we should not run the agent. - logger.Debug(ctx, "the API is closed, not running subagent in container") - return - } - // Update the placeholder with a valid subagent, context and stop. - api.injectedSubAgentProcs[container.ID] = subAgentProcess{ - agent: agent, - ctx: agentCtx, - stop: agentStop, - } - api.mu.Unlock() - - logger.Info(ctx, "starting subagent in dev container") + logger.Info(ctx, "starting subagent in devcontainer") env := []string{ "CODER_AGENT_URL=" + api.subAgentURL, - "CODER_AGENT_TOKEN=" + agent.AuthToken.String(), + "CODER_AGENT_TOKEN=" + proc.agent.AuthToken.String(), } env = append(env, api.subAgentEnv...) - err := api.dccli.Exec(agentCtx, dc.WorkspaceFolder, dc.ConfigPath, agentPath, []string{"agent"}, + err := api.dccli.Exec(proc.ctx, dc.WorkspaceFolder, dc.ConfigPath, agentPath, []string{"agent"}, WithExecContainerID(container.ID), WithRemoteEnv(env...), ) @@ -1229,14 +1227,38 @@ func (api *API) Close() error { api.logger.Debug(api.ctx, "closing API") api.closed = true - for _, proc := range api.injectedSubAgentProcs { - api.logger.Debug(api.ctx, "canceling subagent process", slog.F("agent_name", proc.agent.Name), slog.F("agent_id", proc.agent.ID)) + // Stop all running subagent processes and clean up. + subAgentIDs := make([]uuid.UUID, 0, len(api.injectedSubAgentProcs)) + for workspaceFolder, proc := range api.injectedSubAgentProcs { + api.logger.Debug(api.ctx, "canceling subagent process", + slog.F("agent_name", proc.agent.Name), + slog.F("agent_id", proc.agent.ID), + slog.F("container_id", proc.containerID), + slog.F("workspace_folder", workspaceFolder), + ) proc.stop() + if proc.agent.ID != uuid.Nil { + subAgentIDs = append(subAgentIDs, proc.agent.ID) + } } + api.injectedSubAgentProcs = make(map[string]subAgentProcess) api.cancel() // Interrupt all routines. api.mu.Unlock() // Release lock before waiting for goroutines. + // Note: We can't use api.ctx here because it's canceled. + deleteCtx, deleteCancel := context.WithTimeout(context.Background(), defaultOperationTimeout) + defer deleteCancel() + for _, id := range subAgentIDs { + err := api.subAgentClient.Delete(deleteCtx, id) + if err != nil { + api.logger.Error(api.ctx, "delete subagent record during shutdown failed", + slog.Error(err), + slog.F("agent_id", id), + ) + } + } + // Close the watcher to ensure its loop finishes. err := api.watcher.Close() diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 821117685b50e..92a697b6e23b4 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -9,6 +9,7 @@ import ( "os" "runtime" "strings" + "sync" "testing" "time" @@ -186,7 +187,7 @@ func (w *fakeWatcher) Next(ctx context.Context) (*fsnotify.Event, error) { case <-ctx.Done(): return nil, ctx.Err() case <-w.closeNotify: - return nil, xerrors.New("watcher closed") + return nil, watcher.ErrClosed case event := <-w.events: return event, nil } @@ -212,7 +213,6 @@ func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotif // fakeSubAgentClient implements SubAgentClient for testing purposes. type fakeSubAgentClient struct { agents map[uuid.UUID]agentcontainers.SubAgent - nextID int listErrC chan error // If set, send to return error, close to return nil. created []agentcontainers.SubAgent @@ -222,14 +222,13 @@ type fakeSubAgentClient struct { } func (m *fakeSubAgentClient) List(ctx context.Context) ([]agentcontainers.SubAgent, error) { - var listErr error if m.listErrC != nil { select { case <-ctx.Done(): return nil, ctx.Err() - case err, ok := <-m.listErrC: - if ok { - listErr = err + case err := <-m.listErrC: + if err != nil { + return nil, err } } } @@ -237,22 +236,20 @@ func (m *fakeSubAgentClient) List(ctx context.Context) ([]agentcontainers.SubAge for _, agent := range m.agents { agents = append(agents, agent) } - return agents, listErr + return agents, nil } func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) { - var createErr error if m.createErrC != nil { select { case <-ctx.Done(): return agentcontainers.SubAgent{}, ctx.Err() - case err, ok := <-m.createErrC: - if ok { - createErr = err + case err := <-m.createErrC: + if err != nil { + return agentcontainers.SubAgent{}, err } } } - m.nextID++ agent.ID = uuid.New() agent.AuthToken = uuid.New() if m.agents == nil { @@ -260,18 +257,17 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S } m.agents[agent.ID] = agent m.created = append(m.created, agent) - return agent, createErr + return agent, nil } func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error { - var deleteErr error if m.deleteErrC != nil { select { case <-ctx.Done(): return ctx.Err() - case err, ok := <-m.deleteErrC: - if ok { - deleteErr = err + case err := <-m.deleteErrC: + if err != nil { + return err } } } @@ -280,7 +276,7 @@ func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error { } delete(m.agents, id) m.deleted = append(m.deleted, id) - return deleteErr + return nil } func TestAPI(t *testing.T) { @@ -596,20 +592,19 @@ func TestAPI(t *testing.T) { // Verify the devcontainer is in starting state after recreation // request is made. - req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req := httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code, "status code mismatch") - var resp codersdk.WorkspaceAgentDevcontainersResponse + var resp codersdk.WorkspaceAgentListContainersResponse t.Log(rec.Body.String()) err := json.NewDecoder(rec.Body).Decode(&resp) require.NoError(t, err, "unmarshal response failed") require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response") assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Status, "devcontainer is not starting") require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference") - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not starting") // Allow the devcontainer CLI to continue the up process. close(tt.devcontainerCLI.upErrC) @@ -626,7 +621,7 @@ func TestAPI(t *testing.T) { _, aw = mClock.AdvanceNext() aw.MustWait(ctx) - req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req = httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -637,7 +632,6 @@ func TestAPI(t *testing.T) { require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response after error") assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusError, resp.Devcontainers[0].Status, "devcontainer is not in an error state after up failure") require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference after up failure") - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusError, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not error after up failure") return } @@ -649,7 +643,7 @@ func TestAPI(t *testing.T) { _, aw = mClock.AdvanceNext() aw.MustWait(ctx) - req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req = httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -662,7 +656,6 @@ func TestAPI(t *testing.T) { require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response after recreation") assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp.Devcontainers[0].Status, "devcontainer is not running after recreation") require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference after recreation") - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not running after recreation") }) } }) @@ -757,7 +750,6 @@ func TestAPI(t *testing.T) { assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc.Status) require.NotNil(t, dc.Container) assert.Equal(t, "runtime-container-1", dc.Container.ID) - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc.Container.DevcontainerStatus) }, }, { @@ -802,10 +794,8 @@ func TestAPI(t *testing.T) { require.NotNil(t, known1.Container) assert.Equal(t, "known-container-1", known1.Container.ID) - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, known1.Container.DevcontainerStatus) require.NotNil(t, runtime1.Container) assert.Equal(t, "runtime-container-1", runtime1.Container.ID) - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, runtime1.Container.DevcontainerStatus) }, }, { @@ -845,11 +835,9 @@ func TestAPI(t *testing.T) { require.NotNil(t, running.Container, "running container should have container reference") assert.Equal(t, "running-container", running.Container.ID) - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, running.Container.DevcontainerStatus) require.NotNil(t, nonRunning.Container, "non-running container should have container reference") assert.Equal(t, "non-running-container", nonRunning.Container.ID) - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, nonRunning.Container.DevcontainerStatus) }, }, { @@ -885,7 +873,6 @@ func TestAPI(t *testing.T) { assert.NotEmpty(t, dc2.ConfigPath) require.NotNil(t, dc2.Container) assert.Equal(t, "known-container-2", dc2.Container.ID) - assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc2.Container.DevcontainerStatus) }, }, { @@ -995,7 +982,7 @@ func TestAPI(t *testing.T) { _, aw := mClock.AdvanceNext() aw.MustWait(ctx) - req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req := httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -1006,7 +993,7 @@ func TestAPI(t *testing.T) { return } - var response codersdk.WorkspaceAgentDevcontainersResponse + var response codersdk.WorkspaceAgentListContainersResponse err := json.NewDecoder(rec.Body).Decode(&response) require.NoError(t, err, "unmarshal response failed") @@ -1081,13 +1068,13 @@ func TestAPI(t *testing.T) { }) // Initially the devcontainer should be running and dirty. - req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req := httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec := httptest.NewRecorder() api.Routes().ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - var resp1 codersdk.WorkspaceAgentDevcontainersResponse + var resp1 codersdk.WorkspaceAgentListContainersResponse err := json.NewDecoder(rec.Body).Decode(&resp1) require.NoError(t, err) require.Len(t, resp1.Devcontainers, 1) @@ -1105,13 +1092,13 @@ func TestAPI(t *testing.T) { aw.MustWait(ctx) // Afterwards the devcontainer should not be running and not dirty. - req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req = httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec = httptest.NewRecorder() api.Routes().ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - var resp2 codersdk.WorkspaceAgentDevcontainersResponse + var resp2 codersdk.WorkspaceAgentListContainersResponse err = json.NewDecoder(rec.Body).Decode(&resp2) require.NoError(t, err) require.Len(t, resp2.Devcontainers, 1) @@ -1171,13 +1158,13 @@ func TestAPI(t *testing.T) { // Call the list endpoint first to ensure config files are // detected and watched. - req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req := httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - var response codersdk.WorkspaceAgentDevcontainersResponse + var response codersdk.WorkspaceAgentListContainersResponse err := json.NewDecoder(rec.Body).Decode(&response) require.NoError(t, err) require.Len(t, response.Devcontainers, 1) @@ -1185,8 +1172,6 @@ func TestAPI(t *testing.T) { "devcontainer should not be marked as dirty initially") assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status, "devcontainer should be running initially") require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil") - assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty, - "container should not be marked as dirty initially") // Verify the watcher is watching the config file. assert.Contains(t, fWatcher.addedPaths, configPath, @@ -1207,7 +1192,7 @@ func TestAPI(t *testing.T) { aw.MustWait(ctx) // Check if the container is marked as dirty. - req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req = httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -1220,8 +1205,6 @@ func TestAPI(t *testing.T) { "container should be marked as dirty after config file was modified") assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status, "devcontainer should be running after config file was modified") require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil") - assert.True(t, response.Devcontainers[0].Container.DevcontainerDirty, - "container should be marked as dirty after config file was modified") container.ID = "new-container-id" // Simulate a new container ID after recreation. container.FriendlyName = "new-container-name" @@ -1233,7 +1216,7 @@ func TestAPI(t *testing.T) { aw.MustWait(ctx) // Check if dirty flag is cleared. - req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil). + req = httptest.NewRequest(http.MethodGet, "/", nil). WithContext(ctx) rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -1246,8 +1229,6 @@ func TestAPI(t *testing.T) { "dirty flag should be cleared on the devcontainer after container recreation") assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status, "devcontainer should be running after recreation") require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil") - assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty, - "dirty flag should be cleared on the container after container recreation") }) t.Run("SubAgentLifecycle", func(t *testing.T) { @@ -1289,7 +1270,7 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{testContainer}, - }, nil).AnyTimes() + }, nil).Times(1 + 3) // 1 initial call + 3 updates. gomock.InOrder( mCCLI.EXPECT().DetectArchitecture(gomock.Any(), "test-container-id").Return(runtime.GOARCH, nil), mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), @@ -1300,6 +1281,7 @@ func TestAPI(t *testing.T) { mClock.Set(time.Now()).MustWait(ctx) tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + var closeOnce sync.Once api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), agentcontainers.WithContainerCLI(mCCLI), @@ -1308,12 +1290,17 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentURL("test-subagent-url"), agentcontainers.WithDevcontainerCLI(fakeDCCLI), ) - defer api.Close() + apiClose := func() { + closeOnce.Do(func() { + // Close before api.Close() defer to avoid deadlock after test. + close(fakeSAC.createErrC) + close(fakeSAC.deleteErrC) + close(fakeDCCLI.execErrC) - // Close before api.Close() defer to avoid deadlock after test. - defer close(fakeSAC.createErrC) - defer close(fakeSAC.deleteErrC) - defer close(fakeDCCLI.execErrC) + _ = api.Close() + }) + } + defer apiClose() // Allow initial agent creation and injection to succeed. testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) @@ -1342,9 +1329,27 @@ func TestAPI(t *testing.T) { assert.Len(t, fakeSAC.deleted, 0) } - t.Log("Agent injected successfully, now testing cleanup and reinjection...") + t.Log("Agent injected successfully, now testing reinjection into the same container...") + + // Terminate the agent and verify it can be reinjected. + terminated := make(chan struct{}) + testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(_ string, args ...string) error { + defer close(terminated) + if len(args) > 0 { + assert.Equal(t, "agent", args[0]) + } else { + assert.Fail(t, `want "agent" command argument`) + } + return errTestTermination + }) + <-terminated + + t.Log("Waiting for agent reinjection...") // Expect the agent to be reinjected. + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).Times(3) // 3 updates. gomock.InOrder( mCCLI.EXPECT().DetectArchitecture(gomock.Any(), "test-container-id").Return(runtime.GOARCH, nil), mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), @@ -1352,8 +1357,44 @@ func TestAPI(t *testing.T) { mCCLI.EXPECT().ExecAs(gomock.Any(), "test-container-id", "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), ) - // Terminate the agent and verify it is deleted. + // Allow agent reinjection to succeed. + testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(cmd string, args ...string) error { + assert.Equal(t, "pwd", cmd) + assert.Empty(t, args) + return nil + }) // Exec pwd. + + // Ensure we only inject the agent once. + for i := range 3 { + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + t.Logf("Iteration %d: agents created: %d", i+1, len(fakeSAC.created)) + + // Verify that the agent was reused. + require.Len(t, fakeSAC.created, 1) + assert.Len(t, fakeSAC.deleted, 0) + } + + t.Log("Agent reinjected successfully, now testing agent deletion and recreation...") + + // New container ID means the agent will be recreated. + testContainer.ID = "new-test-container-id" // Simulate a new container ID after recreation. + // Expect the agent to be injected. + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).Times(3) // 3 updates. + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), "new-test-container-id").Return(runtime.GOARCH, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), + mCCLI.EXPECT().Copy(gomock.Any(), "new-test-container-id", coderBin, "/.coder-agent/coder").Return(nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), "new-test-container-id", "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + ) + + // Terminate the agent and verify it can be reinjected. + terminated = make(chan struct{}) testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(_ string, args ...string) error { + defer close(terminated) if len(args) > 0 { assert.Equal(t, "agent", args[0]) } else { @@ -1361,13 +1402,11 @@ func TestAPI(t *testing.T) { } return errTestTermination }) + <-terminated - // Allow cleanup to proceed. + // Simulate the agent deletion. testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil) - - t.Log("Waiting for agent recreation...") - - // Allow agent recreation and reinjection to succeed. + // Expect the agent to be recreated. testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(cmd string, args ...string) error { assert.Equal(t, "pwd", cmd) @@ -1375,20 +1414,25 @@ func TestAPI(t *testing.T) { return nil }) // Exec pwd. - // Wait until the agent recreation is started. - for len(fakeSAC.createErrC) > 0 { + // Advance the clock to run updaterLoop. + for i := range 3 { _, aw := mClock.AdvanceNext() aw.MustWait(ctx) + + t.Logf("Iteration %d: agents created: %d, deleted: %d", i+1, len(fakeSAC.created), len(fakeSAC.deleted)) } - t.Log("Agent recreated successfully.") + // Verify the agent was deleted and recreated. + require.Len(t, fakeSAC.deleted, 1, "there should be one deleted agent after recreation") + assert.Len(t, fakeSAC.created, 2, "there should be two created agents after recreation") + assert.Equal(t, fakeSAC.created[0].ID, fakeSAC.deleted[0], "the deleted agent should match the first created agent") - // Verify agent was deleted. - require.Len(t, fakeSAC.deleted, 1) - assert.Equal(t, fakeSAC.created[0].ID, fakeSAC.deleted[0]) + t.Log("Agent deleted and recreated successfully.") - // Verify the agent recreated. - require.Len(t, fakeSAC.created, 2) + apiClose() + require.Len(t, fakeSAC.created, 2, "API close should not create more agents") + require.Len(t, fakeSAC.deleted, 2, "API close should delete the agent") + assert.Equal(t, fakeSAC.created[1].ID, fakeSAC.deleted[1], "the second created agent should be deleted on API close") }) t.Run("SubAgentCleanup", func(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5dc293e2e706e..975e094b52fc6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17500,18 +17500,6 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, - "devcontainer_dirty": { - "description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.", - "type": "boolean" - }, - "devcontainer_status": { - "description": "DevcontainerStatus is the status of the devcontainer, if this\ncontainer is a devcontainer. This is used to determine if the\ndevcontainer is running, stopped, starting, or in an error state.", - "allOf": [ - { - "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerStatus" - } - ] - }, "id": { "description": "ID is the unique identifier of the container.", "type": "string" @@ -17576,6 +17564,56 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceAgentDevcontainer": { + "type": "object", + "properties": { + "agent": { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerAgent" + }, + "config_path": { + "type": "string" + }, + "container": { + "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" + }, + "dirty": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "description": "Additional runtime fields.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerStatus" + } + ] + }, + "workspace_folder": { + "type": "string" + } + } + }, + "codersdk.WorkspaceAgentDevcontainerAgent": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, "codersdk.WorkspaceAgentDevcontainerStatus": { "type": "string", "enum": [ @@ -17641,6 +17679,13 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" } }, + "devcontainers": { + "description": "Devcontainers is a list of devcontainers visible to the workspace agent.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainer" + } + }, "warnings": { "description": "Warnings is a list of warnings that may have occurred during the\nprocess of listing containers. This should not include fatal errors.", "type": "array", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ff48e99d393fc..7b71741a205d5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15991,18 +15991,6 @@ "type": "string", "format": "date-time" }, - "devcontainer_dirty": { - "description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.", - "type": "boolean" - }, - "devcontainer_status": { - "description": "DevcontainerStatus is the status of the devcontainer, if this\ncontainer is a devcontainer. This is used to determine if the\ndevcontainer is running, stopped, starting, or in an error state.", - "allOf": [ - { - "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerStatus" - } - ] - }, "id": { "description": "ID is the unique identifier of the container.", "type": "string" @@ -16067,6 +16055,56 @@ } } }, + "codersdk.WorkspaceAgentDevcontainer": { + "type": "object", + "properties": { + "agent": { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerAgent" + }, + "config_path": { + "type": "string" + }, + "container": { + "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" + }, + "dirty": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "description": "Additional runtime fields.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerStatus" + } + ] + }, + "workspace_folder": { + "type": "string" + } + } + }, + "codersdk.WorkspaceAgentDevcontainerAgent": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, "codersdk.WorkspaceAgentDevcontainerStatus": { "type": "string", "enum": ["running", "stopped", "starting", "error"], @@ -16127,6 +16165,13 @@ "$ref": "#/definitions/codersdk.WorkspaceAgentContainer" } }, + "devcontainers": { + "description": "Devcontainers is a list of devcontainers visible to the workspace agent.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainer" + } + }, "warnings": { "description": "Warnings is a list of warnings that may have occurred during the\nprocess of listing containers. This should not include fatal errors.", "type": "array", diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index ec0b692886918..6d53bd3df1140 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1403,15 +1403,13 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { agentcontainers.DevcontainerConfigFileLabel: configFile, } devContainer = codersdk.WorkspaceAgentContainer{ - ID: uuid.NewString(), - CreatedAt: dbtime.Now(), - FriendlyName: testutil.GetRandomName(t), - Image: "busybox:latest", - Labels: dcLabels, - Running: true, - Status: "running", - DevcontainerDirty: true, - DevcontainerStatus: codersdk.WorkspaceAgentDevcontainerStatusRunning, + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: dcLabels, + Running: true, + Status: "running", } plainContainer = codersdk.WorkspaceAgentContainer{ ID: uuid.NewString(), diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 6a4380fed47ac..5fe648ce15045 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -393,12 +393,6 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } -// WorkspaceAgentDevcontainersResponse is the response to the devcontainers -// request. -type WorkspaceAgentDevcontainersResponse struct { - Devcontainers []WorkspaceAgentDevcontainer `json:"devcontainers"` -} - // WorkspaceAgentDevcontainerStatus is the status of a devcontainer. type WorkspaceAgentDevcontainerStatus string @@ -422,6 +416,15 @@ type WorkspaceAgentDevcontainer struct { Status WorkspaceAgentDevcontainerStatus `json:"status"` Dirty bool `json:"dirty"` Container *WorkspaceAgentContainer `json:"container,omitempty"` + Agent *WorkspaceAgentDevcontainerAgent `json:"agent,omitempty"` +} + +// WorkspaceAgentDevcontainerAgent represents the sub agent for a +// devcontainer. +type WorkspaceAgentDevcontainerAgent struct { + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + Directory string `json:"directory"` } // WorkspaceAgentContainer describes a devcontainer of some sort @@ -450,14 +453,6 @@ type WorkspaceAgentContainer struct { // Volumes is a map of "things" mounted into the container. Again, this // is somewhat implementation-dependent. Volumes map[string]string `json:"volumes"` - // DevcontainerStatus is the status of the devcontainer, if this - // container is a devcontainer. This is used to determine if the - // devcontainer is running, stopped, starting, or in an error state. - DevcontainerStatus WorkspaceAgentDevcontainerStatus `json:"devcontainer_status,omitempty"` - // DevcontainerDirty is true if the devcontainer configuration has changed - // since the container was created. This is used to determine if the - // container needs to be rebuilt. - DevcontainerDirty bool `json:"devcontainer_dirty"` } func (c *WorkspaceAgentContainer) Match(idOrName string) bool { @@ -486,6 +481,8 @@ type WorkspaceAgentContainerPort struct { // WorkspaceAgentListContainersResponse is the response to the list containers // request. type WorkspaceAgentListContainersResponse struct { + // Devcontainers is a list of devcontainers visible to the workspace agent. + Devcontainers []WorkspaceAgentDevcontainer `json:"devcontainers"` // Containers is a list of containers visible to the workspace agent. Containers []WorkspaceAgentContainer `json:"containers"` // Warnings is a list of warnings that may have occurred during the diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 5ef747066b6ab..1c0534ad4c2bf 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -777,8 +777,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "containers": [ { "created_at": "2019-08-24T14:15:22Z", - "devcontainer_dirty": true, - "devcontainer_status": "running", "id": "string", "image": "string", "labels": { @@ -802,6 +800,45 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con } } ], + "devcontainers": [ + { + "agent": { + "directory": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "config_path": "string", + "container": { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + }, + "dirty": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": "running", + "workspace_folder": "string" + } + ], "warnings": [ "string" ] diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a5b759e5dfb0c..b4bb668a8a89d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9011,8 +9011,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "created_at": "2019-08-24T14:15:22Z", - "devcontainer_dirty": true, - "devcontainer_status": "running", "id": "string", "image": "string", "labels": { @@ -9039,21 +9037,19 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|----------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `created_at` | string | false | | Created at is the time the container was created. | -| `devcontainer_dirty` | boolean | false | | Devcontainer dirty is true if the devcontainer configuration has changed since the container was created. This is used to determine if the container needs to be rebuilt. | -| `devcontainer_status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Devcontainer status is the status of the devcontainer, if this container is a devcontainer. This is used to determine if the devcontainer is running, stopped, starting, or in an error state. | -| `id` | string | false | | ID is the unique identifier of the container. | -| `image` | string | false | | Image is the name of the container image. | -| `labels` | object | false | | Labels is a map of key-value pairs of container labels. | -| » `[any property]` | string | false | | | -| `name` | string | false | | Name is the human-readable name of the container. | -| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. | -| `running` | boolean | false | | Running is true if the container is currently running. | -| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. | -| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. | -| » `[any property]` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|---------------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `created_at` | string | false | | Created at is the time the container was created. | +| `id` | string | false | | ID is the unique identifier of the container. | +| `image` | string | false | | Image is the name of the container image. | +| `labels` | object | false | | Labels is a map of key-value pairs of container labels. | +| » `[any property]` | string | false | | | +| `name` | string | false | | Name is the human-readable name of the container. | +| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. | +| `running` | boolean | false | | Running is true if the container is currently running. | +| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. | +| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. | +| » `[any property]` | string | false | | | ## codersdk.WorkspaceAgentContainerPort @@ -9075,6 +9071,79 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `network` | string | false | | Network is the network protocol used by the port (tcp, udp, etc). | | `port` | integer | false | | Port is the port number *inside* the container. | +## codersdk.WorkspaceAgentDevcontainer + +```json +{ + "agent": { + "directory": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "config_path": "string", + "container": { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + }, + "dirty": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": "running", + "workspace_folder": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|----------------------------------------------------------------------------------------|----------|--------------|----------------------------| +| `agent` | [codersdk.WorkspaceAgentDevcontainerAgent](#codersdkworkspaceagentdevcontaineragent) | false | | | +| `config_path` | string | false | | | +| `container` | [codersdk.WorkspaceAgentContainer](#codersdkworkspaceagentcontainer) | false | | | +| `dirty` | boolean | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Additional runtime fields. | +| `workspace_folder` | string | false | | | + +## codersdk.WorkspaceAgentDevcontainerAgent + +```json +{ + "directory": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|--------|----------|--------------|-------------| +| `directory` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | + ## codersdk.WorkspaceAgentDevcontainerStatus ```json @@ -9137,8 +9206,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| "containers": [ { "created_at": "2019-08-24T14:15:22Z", - "devcontainer_dirty": true, - "devcontainer_status": "running", "id": "string", "image": "string", "labels": { @@ -9162,6 +9229,45 @@ If the schedule is empty, the user will be updated to use the default schedule.| } } ], + "devcontainers": [ + { + "agent": { + "directory": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "config_path": "string", + "container": { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + }, + "dirty": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": "running", + "workspace_folder": "string" + } + ], "warnings": [ "string" ] @@ -9170,10 +9276,11 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------|-------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------| -| `containers` | array of [codersdk.WorkspaceAgentContainer](#codersdkworkspaceagentcontainer) | false | | Containers is a list of containers visible to the workspace agent. | -| `warnings` | array of string | false | | Warnings is a list of warnings that may have occurred during the process of listing containers. This should not include fatal errors. | +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------| +| `containers` | array of [codersdk.WorkspaceAgentContainer](#codersdkworkspaceagentcontainer) | false | | Containers is a list of containers visible to the workspace agent. | +| `devcontainers` | array of [codersdk.WorkspaceAgentDevcontainer](#codersdkworkspaceagentdevcontainer) | false | | Devcontainers is a list of devcontainers visible to the workspace agent. | +| `warnings` | array of string | false | | Warnings is a list of warnings that may have occurred during the process of listing containers. This should not include fatal errors. | ## codersdk.WorkspaceAgentListeningPort diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a512305c489d3..96a4fbe75e834 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3354,8 +3354,6 @@ export interface WorkspaceAgentContainer { readonly ports: readonly WorkspaceAgentContainerPort[]; readonly status: string; readonly volumes: Record; - readonly devcontainer_status?: WorkspaceAgentDevcontainerStatus; - readonly devcontainer_dirty: boolean; } // From codersdk/workspaceagents.go @@ -3375,6 +3373,14 @@ export interface WorkspaceAgentDevcontainer { readonly status: WorkspaceAgentDevcontainerStatus; readonly dirty: boolean; readonly container?: WorkspaceAgentContainer; + readonly agent?: WorkspaceAgentDevcontainerAgent; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentDevcontainerAgent { + readonly id: string; + readonly name: string; + readonly directory: string; } // From codersdk/workspaceagents.go @@ -3387,11 +3393,6 @@ export type WorkspaceAgentDevcontainerStatus = export const WorkspaceAgentDevcontainerStatuses: WorkspaceAgentDevcontainerStatus[] = ["error", "running", "starting", "stopped"]; -// From codersdk/workspaceagents.go -export interface WorkspaceAgentDevcontainersResponse { - readonly devcontainers: readonly WorkspaceAgentDevcontainer[]; -} - // From codersdk/workspaceagents.go export interface WorkspaceAgentHealth { readonly healthy: boolean; @@ -3424,6 +3425,7 @@ export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [ // From codersdk/workspaceagents.go export interface WorkspaceAgentListContainersResponse { + readonly devcontainers: readonly WorkspaceAgentDevcontainer[]; readonly containers: readonly WorkspaceAgentContainer[]; readonly warnings?: readonly string[]; } diff --git a/site/src/modules/resources/AgentApps/AgentApps.tsx b/site/src/modules/resources/AgentApps/AgentApps.tsx new file mode 100644 index 0000000000000..75793ef7a82c7 --- /dev/null +++ b/site/src/modules/resources/AgentApps/AgentApps.tsx @@ -0,0 +1,100 @@ +import type { WorkspaceApp } from "api/typesGenerated"; +import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { Folder } from "lucide-react"; +import type { FC } from "react"; +import { AgentButton } from "../AgentButton"; +import { AppLink } from "../AppLink/AppLink"; + +type AgentAppsProps = { + section: AgentAppSection; + agent: WorkspaceAgent; + workspace: Workspace; +}; + +export const AgentApps: FC = ({ + section, + agent, + workspace, +}) => { + return section.group ? ( + + + + + {section.group} + + + + {section.apps.map((app) => ( + + + + ))} + + + ) : ( + <> + {section.apps.map((app) => ( + + ))} + + ); +}; + +type AgentAppSection = { + /** + * If there is no `group`, just render all of the apps inline. If there is a + * group name, show them all in a dropdown. + */ + group?: string; + + apps: WorkspaceApp[]; +}; + +/** + * Groups apps by their `group` property. Apps with the same group are placed + * in the same section. Apps without a group are placed in their own section. + * + * The algorithm assumes that apps are already sorted by group, meaning that + * every ungrouped section is expected to have a group in between, to make the + * algorithm a little simpler to implement. + */ +export function organizeAgentApps( + apps: readonly WorkspaceApp[], +): AgentAppSection[] { + let currentSection: AgentAppSection | undefined = undefined; + const appGroups: AgentAppSection[] = []; + const groupsByName = new Map(); + + for (const app of apps) { + if (app.hidden) { + continue; + } + + if (!currentSection || app.group !== currentSection.group) { + const existingSection = groupsByName.get(app.group!); + if (existingSection) { + currentSection = existingSection; + } else { + currentSection = { + group: app.group, + apps: [], + }; + appGroups.push(currentSection); + if (app.group) { + groupsByName.set(app.group, currentSection); + } + } + } + + currentSection.apps.push(app); + } + + return appGroups; +} diff --git a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx index fdd85d95c4849..1f798b7540f79 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx @@ -1,20 +1,45 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { getPreferredProxy } from "contexts/ProxyContext"; +import { chromatic } from "testHelpers/chromatic"; import { + MockListeningPortsResponse, + MockPrimaryWorkspaceProxy, + MockTemplate, MockWorkspace, MockWorkspaceAgent, MockWorkspaceAgentContainer, MockWorkspaceAgentContainerPorts, + MockWorkspaceAgentDevcontainer, + MockWorkspaceApp, + MockWorkspaceProxies, + MockWorkspaceSubAgent, } from "testHelpers/entities"; +import { + withDashboardProvider, + withProxyProvider, +} from "testHelpers/storybook"; import { AgentDevcontainerCard } from "./AgentDevcontainerCard"; const meta: Meta = { title: "modules/resources/AgentDevcontainerCard", component: AgentDevcontainerCard, args: { - container: MockWorkspaceAgentContainer, + devcontainer: MockWorkspaceAgentDevcontainer, workspace: MockWorkspace, wildcardHostname: "*.wildcard.hostname", - agent: MockWorkspaceAgent, + parentAgent: MockWorkspaceAgent, + template: MockTemplate, + subAgents: [MockWorkspaceSubAgent], + }, + decorators: [withProxyProvider(), withDashboardProvider], + parameters: { + chromatic, + queries: [ + { + key: ["portForward", MockWorkspaceSubAgent.id], + data: MockListeningPortsResponse, + }, + ], }, }; @@ -25,30 +50,81 @@ export const NoPorts: Story = {}; export const WithPorts: Story = { args: { - container: { - ...MockWorkspaceAgentContainer, - ports: MockWorkspaceAgentContainerPorts, + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + container: { + ...MockWorkspaceAgentContainer, + ports: MockWorkspaceAgentContainerPorts, + }, }, }, }; export const Dirty: Story = { args: { - container: { - ...MockWorkspaceAgentContainer, - devcontainer_dirty: true, - ports: MockWorkspaceAgentContainerPorts, + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + dirty: true, }, }, }; export const Recreating: Story = { args: { - container: { - ...MockWorkspaceAgentContainer, - devcontainer_dirty: true, - devcontainer_status: "starting", - ports: MockWorkspaceAgentContainerPorts, + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + dirty: true, + status: "starting", + container: undefined, + }, + subAgents: [], + }, +}; + +export const NoSubAgent: Story = { + args: { + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + agent: undefined, + }, + subAgents: [], + }, +}; + +export const SubAgentConnecting: Story = { + args: { + subAgents: [ + { + ...MockWorkspaceSubAgent, + status: "connecting", + }, + ], + }, +}; + +export const WithAppsAndPorts: Story = { + args: { + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + container: { + ...MockWorkspaceAgentContainer, + ports: MockWorkspaceAgentContainerPorts, + }, }, + subAgents: [ + { + ...MockWorkspaceSubAgent, + apps: [MockWorkspaceApp], + }, + ], }, }; + +export const WithPortForwarding: Story = { + decorators: [ + withProxyProvider({ + proxy: getPreferredProxy(MockWorkspaceProxies, MockPrimaryWorkspaceProxy), + proxies: MockWorkspaceProxies, + }), + ], +}; diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index 65b32593c1418..9ba6e26c5d46a 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -1,117 +1,245 @@ +import Skeleton from "@mui/material/Skeleton"; import type { + Template, Workspace, WorkspaceAgent, - WorkspaceAgentContainer, + WorkspaceAgentDevcontainer, + WorkspaceAgentListContainersResponse, } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { displayError } from "components/GlobalSnackbar/utils"; -import { - HelpTooltip, - HelpTooltipContent, - HelpTooltipText, - HelpTooltipTitle, - HelpTooltipTrigger, -} from "components/HelpTooltip/HelpTooltip"; import { Spinner } from "components/Spinner/Spinner"; +import { Stack } from "components/Stack/Stack"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { ExternalLinkIcon } from "lucide-react"; +import { useProxy } from "contexts/ProxyContext"; +import { Container, ExternalLinkIcon } from "lucide-react"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { AppStatuses } from "pages/WorkspacePage/AppStatuses"; import type { FC } from "react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; +import { useMutation, useQueryClient } from "react-query"; import { portForwardURL } from "utils/portForward"; +import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; import { AgentButton } from "./AgentButton"; -import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton"; +import { AgentLatency } from "./AgentLatency"; +import { SubAgentStatus } from "./AgentStatus"; +import { PortForwardButton } from "./PortForwardButton"; +import { AgentSSHButton } from "./SSHButton/SSHButton"; +import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip"; import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton/VSCodeDevContainerButton"; type AgentDevcontainerCardProps = { - agent: WorkspaceAgent; - container: WorkspaceAgentContainer; + parentAgent: WorkspaceAgent; + subAgents: WorkspaceAgent[]; + devcontainer: WorkspaceAgentDevcontainer; workspace: Workspace; + template: Template; wildcardHostname: string; }; export const AgentDevcontainerCard: FC = ({ - agent, - container, + parentAgent, + subAgents, + devcontainer, workspace, + template, wildcardHostname, }) => { - const folderPath = container.labels["devcontainer.local_folder"]; - const containerFolder = container.volumes[folderPath]; - const [isRecreating, setIsRecreating] = useState(false); - - const handleRecreateDevcontainer = async () => { - setIsRecreating(true); - let recreateSucceeded = false; - try { + const { browser_only } = useFeatureVisibility(); + const { proxy } = useProxy(); + const queryClient = useQueryClient(); + + // The sub agent comes from the workspace response whereas the devcontainer + // comes from the agent containers endpoint. We need alignment between the + // two, so if the sub agent is not present or the IDs do not match, we + // assume it has been removed. + const subAgent = subAgents.find((sub) => sub.id === devcontainer.agent?.id); + + const appSections = (subAgent && organizeAgentApps(subAgent.apps)) || []; + const displayApps = + subAgent?.display_apps.filter((app) => { + if (browser_only) { + return ["web_terminal", "port_forwarding_helper"].includes(app); + } + return true; + }) || []; + const showVSCode = + devcontainer.container && + (displayApps.includes("vscode") || displayApps.includes("vscode_insiders")); + const hasAppsToDisplay = + displayApps.includes("web_terminal") || + showVSCode || + appSections.some((it) => it.apps.length > 0); + + const rebuildDevcontainerMutation = useMutation({ + mutationFn: async () => { const response = await fetch( - `/api/v2/workspaceagents/${agent.id}/containers/devcontainers/container/${container.id}/recreate`, - { - method: "POST", - }, + `/api/v2/workspaceagents/${parentAgent.id}/containers/devcontainers/container/${devcontainer.container?.id}/recreate`, + { method: "POST" }, ); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( - errorData.message || `Failed to recreate: ${response.statusText}`, + errorData.message || `Failed to rebuild: ${response.statusText}`, ); } - // If the request was accepted (e.g. 202), we mark it as succeeded. - // Once complete, the component will unmount, so the spinner will - // disappear with it. - if (response.status === 202) { - recreateSucceeded = true; + return response; + }, + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: ["agents", parentAgent.id, "containers"], + }); + + // Snapshot the previous data for rollback in case of error. + const previousData = queryClient.getQueryData([ + "agents", + parentAgent.id, + "containers", + ]); + + // Optimistically update the devcontainer status to + // "starting" and zero the agent and container to mimic what + // the API does. + queryClient.setQueryData( + ["agents", parentAgent.id, "containers"], + (oldData?: WorkspaceAgentListContainersResponse) => { + if (!oldData?.devcontainers) return oldData; + return { + ...oldData, + devcontainers: oldData.devcontainers.map((dc) => { + if (dc.id === devcontainer.id) { + return { + ...dc, + agent: null, + container: null, + status: "starting", + }; + } + return dc; + }), + }; + }, + ); + + return { previousData }; + }, + onSuccess: async () => { + // Invalidate the containers query to refetch updated data. + await queryClient.invalidateQueries({ + queryKey: ["agents", parentAgent.id, "containers"], + }); + }, + onError: (error, _, context) => { + // If the mutation fails, use the context returned from + // onMutate to roll back. + if (context?.previousData) { + queryClient.setQueryData( + ["agents", parentAgent.id, "containers"], + context.previousData, + ); } - } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred."; - displayError(`Failed to recreate devcontainer: ${errorMessage}`); - console.error("Failed to recreate devcontainer:", error); - } finally { - if (!recreateSucceeded) { - setIsRecreating(false); - } - } - }; + displayError(`Failed to rebuild devcontainer: ${errorMessage}`); + console.error("Failed to rebuild devcontainer:", error); + }, + }); - // If the container is starting, reflect this in the recreate button. + // Re-fetch containers when the subAgent changes to ensure data is + // in sync. This relies on agent updates being pushed to the client + // to trigger the re-fetch. That is why we match on name here + // instead of ID as we need to fetch to get an up-to-date ID. + const latestSubAgentByName = subAgents.find( + (agent) => agent.name === devcontainer.name, + ); useEffect(() => { - if (container.devcontainer_status === "starting") { - setIsRecreating(true); - } else { - setIsRecreating(false); + if (!latestSubAgentByName?.id || !latestSubAgentByName?.status) { + return; } - }, [container.devcontainer_status]); + queryClient.invalidateQueries({ + queryKey: ["agents", parentAgent.id, "containers"], + }); + }, [ + latestSubAgentByName?.id, + latestSubAgentByName?.status, + queryClient, + parentAgent.id, + ]); + + const showDevcontainerControls = subAgent && devcontainer.container; + const showSubAgentApps = + devcontainer.status !== "starting" && + subAgent?.status === "connected" && + hasAppsToDisplay; + const showSubAgentAppsPlaceholders = + devcontainer.status === "starting" || subAgent?.status === "connecting"; + + const handleRebuildDevcontainer = () => { + rebuildDevcontainerMutation.mutate(); + }; + + const appsClasses = "flex flex-wrap gap-4 empty:hidden md:justify-start"; return ( -
-
-
-

- dev container:{" "} - {container.name} -

- {container.devcontainer_dirty && ( - - - Outdated - - - Devcontainer Outdated - - Devcontainer configuration has been modified and is outdated. - Recreate to get an up-to-date container. - - - +
+ + dev container +
+
+
+
+ + + {subAgent?.name ?? devcontainer.name} + {devcontainer.container && ( + + {" "} + ({devcontainer.container.name}) + + )} + +
+ {subAgent?.status === "connected" && ( + <> + + + + )} + {subAgent?.status === "connecting" && ( + <> + + + )}
@@ -119,73 +247,129 @@ export const AgentDevcontainerCard: FC = ({ - + {showDevcontainerControls && displayApps.includes("ssh_helper") && ( + + )} + {showDevcontainerControls && + displayApps.includes("port_forwarding_helper") && + proxy.preferredWildcardHostname !== "" && ( + + )}
-

Forwarded ports

- -
- - - - {wildcardHostname !== "" && - container.ports.map((port) => { - const portLabel = `${port.port}/${port.network.toUpperCase()}`; - const hasHostBind = - port.host_port !== undefined && port.host_ip !== undefined; - const helperText = hasHostBind - ? `${port.host_ip}:${port.host_port}` - : "Not bound to host"; - const linkDest = hasHostBind - ? portForwardURL( - wildcardHostname, - port.host_port, - agent.name, - workspace.name, - workspace.owner_name, - location.protocol === "https" ? "https" : "http", - ) - : ""; - return ( - - - - - - - {portLabel} - - - - {helperText} - - - ); - })} -
-
+ {(showSubAgentApps || showSubAgentAppsPlaceholders) && ( +
+ {subAgent && + workspace.latest_app_status?.agent_id === subAgent.id && ( +
+

App statuses

+ +
+ )} + + {showSubAgentApps && ( +
+ <> + {showVSCode && ( + + )} + {appSections.map((section, i) => ( + + ))} + + + {displayApps.includes("web_terminal") && ( + + )} + + {wildcardHostname !== "" && + devcontainer.container?.ports.map((port) => { + const portLabel = `${port.port}/${port.network.toUpperCase()}`; + const hasHostBind = + port.host_port !== undefined && port.host_ip !== undefined; + const helperText = hasHostBind + ? `${port.host_ip}:${port.host_port}` + : "Not bound to host"; + const linkDest = hasHostBind + ? portForwardURL( + wildcardHostname, + port.host_port, + subAgent.name, + workspace.name, + workspace.owner_name, + location.protocol === "https" ? "https" : "http", + ) + : ""; + return ( + + + + + + + {portLabel} + + + + {helperText} + + + ); + })} +
+ )} + + {showSubAgentAppsPlaceholders && ( +
+ + +
+ )} +
+ )} + ); }; diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index afeb95d0d2177..a5ad16ae9f97b 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -288,6 +288,7 @@ export const GroupApp: Story = { export const Devcontainer: Story = { beforeEach: () => { spyOn(API, "getAgentContainers").mockResolvedValue({ + devcontainers: [M.MockWorkspaceAgentDevcontainer], containers: [M.MockWorkspaceAgentContainer], }); }, diff --git a/site/src/modules/resources/AgentRow.test.tsx b/site/src/modules/resources/AgentRow.test.tsx index 55b14704ad7a6..3af0575890320 100644 --- a/site/src/modules/resources/AgentRow.test.tsx +++ b/site/src/modules/resources/AgentRow.test.tsx @@ -1,5 +1,5 @@ import { MockWorkspaceApp } from "testHelpers/entities"; -import { organizeAgentApps } from "./AgentRow"; +import { organizeAgentApps } from "./AgentApps/AgentApps"; describe("organizeAgentApps", () => { test("returns one ungrouped app", () => { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index d7545ff5c8430..54ffe229b2ecd 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -8,20 +8,12 @@ import type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata, - WorkspaceApp, } from "api/typesGenerated"; import { isAxiosError } from "axios"; import { Button } from "components/Button/Button"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "components/DropdownMenu/DropdownMenu"; import { Stack } from "components/Stack/Stack"; import { useProxy } from "contexts/ProxyContext"; -import { Folder } from "lucide-react"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { AppStatuses } from "pages/WorkspacePage/AppStatuses"; import { @@ -36,7 +28,7 @@ import { import { useQuery } from "react-query"; import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; -import { AgentButton } from "./AgentButton"; +import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; import { AgentDevcontainerCard } from "./AgentDevcontainerCard"; import { AgentLatency } from "./AgentLatency"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine"; @@ -44,7 +36,6 @@ import { AgentLogs } from "./AgentLogs/AgentLogs"; import { AgentMetadata } from "./AgentMetadata"; import { AgentStatus } from "./AgentStatus"; import { AgentVersion } from "./AgentVersion"; -import { AppLink } from "./AppLink/AppLink"; import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton"; import { PortForwardButton } from "./PortForwardButton"; import { AgentSSHButton } from "./SSHButton/SSHButton"; @@ -54,6 +45,7 @@ import { useAgentLogs } from "./useAgentLogs"; interface AgentRowProps { agent: WorkspaceAgent; + subAgents?: WorkspaceAgent[]; workspace: Workspace; template: Template; initialMetadata?: WorkspaceAgentMetadata[]; @@ -62,6 +54,7 @@ interface AgentRowProps { export const AgentRow: FC = ({ agent, + subAgents, workspace, template, onUpdateAgent, @@ -140,16 +133,11 @@ export const AgentRow: FC = ({ setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT); }, []); - const { data: containers } = useQuery({ + const { data: devcontainers } = useQuery({ queryKey: ["agents", agent.id, "containers"], - queryFn: () => - // Only return devcontainers - API.getAgentContainers(agent.id, [ - "devcontainer.config_file=", - "devcontainer.local_folder=", - ]), + queryFn: () => API.getAgentContainers(agent.id), enabled: agent.status === "connected", - select: (res) => res.containers.filter((c) => c.status === "running"), + select: (res) => res.devcontainers, // TODO: Implement a websocket connection to get updates on containers // without having to poll. refetchInterval: ({ state }) => { @@ -164,7 +152,7 @@ export const AgentRow: FC = ({ const [showParentApps, setShowParentApps] = useState(false); let shouldDisplayAppsSection = shouldDisplayAgentApps; - if (containers && containers.length > 0 && !showParentApps) { + if (devcontainers && devcontainers.length > 0 && !showParentApps) { shouldDisplayAppsSection = false; } @@ -200,7 +188,7 @@ export const AgentRow: FC = ({
- {containers && containers.length > 0 && ( + {devcontainers && devcontainers.length > 0 && ( - - - - - Run the following commands to connect with SSH: - - -
    - - - -
- - - - Install Coder CLI - - - SSH configuration - - -
- - ); -}; - interface SSHStepProps { helpText: string; codeExample: string; @@ -151,11 +101,11 @@ const SSHStep: FC = ({ helpText, codeExample }) => ( const classNames = { paper: (css, theme) => css` - padding: 16px 24px 24px; - width: 304px; - color: ${theme.palette.text.secondary}; - margin-top: 2px; - `, + padding: 16px 24px 24px; + width: 304px; + color: ${theme.palette.text.secondary}; + margin-top: 2px; + `, } satisfies Record; const styles = { diff --git a/site/src/modules/resources/SubAgentOutdatedTooltip.tsx b/site/src/modules/resources/SubAgentOutdatedTooltip.tsx new file mode 100644 index 0000000000000..c32b4c30c863b --- /dev/null +++ b/site/src/modules/resources/SubAgentOutdatedTooltip.tsx @@ -0,0 +1,67 @@ +import type { + WorkspaceAgent, + WorkspaceAgentDevcontainer, +} from "api/typesGenerated"; +import { + HelpTooltip, + HelpTooltipAction, + HelpTooltipContent, + HelpTooltipLinksGroup, + HelpTooltipText, + HelpTooltipTitle, + HelpTooltipTrigger, +} from "components/HelpTooltip/HelpTooltip"; +import { Stack } from "components/Stack/Stack"; +import { RotateCcwIcon } from "lucide-react"; +import type { FC } from "react"; + +type SubAgentOutdatedTooltipProps = { + devcontainer: WorkspaceAgentDevcontainer; + agent: WorkspaceAgent; + onUpdate: () => void; +}; + +export const SubAgentOutdatedTooltip: FC = ({ + devcontainer, + agent, + onUpdate, +}) => { + if (!devcontainer.agent || devcontainer.agent.id !== agent.id) { + return null; + } + if (!devcontainer.dirty) { + return null; + } + + const title = "Dev Container Outdated"; + const opener = "This Dev Container is outdated."; + const text = `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`; + + return ( + + + + Outdated + + + + +
+ {title} + {text} +
+ + + + Rebuild Dev Container + + +
+
+
+ ); +}; diff --git a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx index 42e0a5bd75db4..ffaef3e13016c 100644 --- a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx +++ b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx @@ -101,9 +101,9 @@ export const VSCodeDevContainerButton: FC = ( ) : includesVSCodeDesktop ? ( - ) : ( + ) : includesVSCodeInsiders ? ( - ); + ) : null; }; const VSCodeButton: FC = ({ diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 978a58e8cb0e1..4fb197e6b5146 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -97,7 +97,7 @@ export const RunningWithChildAgent: Story = { lifecycle_state: "ready", }, { - ...Mocks.MockWorkspaceChildAgent, + ...Mocks.MockWorkspaceSubAgent, lifecycle_state: "ready", }, ], diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 65c924354ceb0..5c032c04efbdf 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -242,6 +242,9 @@ export const Workspace: FC = ({ a.parent_id === agent.id, + )} workspace={workspace} template={template} onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0201e4b563efc..5b391f359f2ad 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -970,38 +970,15 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { ], }; -export const MockWorkspaceChildAgent: TypesGen.WorkspaceAgent = { +export const MockWorkspaceSubAgent: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, apps: [], - architecture: "amd64", - created_at: "", - environment_variables: {}, - id: "test-workspace-child-agent", + id: "test-workspace-sub-agent", parent_id: "test-workspace-agent", - name: "a-workspace-child-agent", - operating_system: "linux", - resource_id: "", - status: "connected", - updated_at: "", - version: MockBuildInfo.version, - api_version: MockBuildInfo.agent_api_version, - latency: { - "Coder Embedded DERP": { - latency_ms: 32.55, - preferred: true, - }, - }, - connection_timeout_seconds: 120, - troubleshooting_url: "https://coder.com/troubleshoot", - lifecycle_state: "starting", - logs_length: 0, - logs_overflowed: false, - log_sources: [MockWorkspaceAgentLogSource], + name: "a-workspace-sub-agent", + log_sources: [], scripts: [], - startup_script_behavior: "non-blocking", - subsystems: ["envbox", "exectrace"], - health: { - healthy: true, - }, + directory: "/workspace/test", display_apps: [ "ssh_helper", "port_forwarding_helper", @@ -4390,9 +4367,24 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = { volumes: { "/mnt/volume1": "/volume1", }, - devcontainer_dirty: false, }; +export const MockWorkspaceAgentDevcontainer: TypesGen.WorkspaceAgentDevcontainer = + { + id: "test-devcontainer-id", + name: "test-devcontainer", + workspace_folder: "/workspace/test", + config_path: "/workspace/test/.devcontainer/devcontainer.json", + status: "running", + dirty: false, + container: MockWorkspaceAgentContainer, + agent: { + id: MockWorkspaceSubAgent.id, + name: MockWorkspaceSubAgent.name, + directory: MockWorkspaceSubAgent?.directory ?? "/workspace/test", + }, + }; + export const MockWorkspaceAppStatuses: TypesGen.WorkspaceAppStatus[] = [ { // This is the latest status chronologically (15:04:38) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy