From f144a033ee9b36248b1a19c7705428514a3092d4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 14 May 2025 15:51:07 +0000 Subject: [PATCH 1/6] feat(agent): send devcontainer CLI logs to coderd during recreate We need a way to surface what's happening to the user, since autostart logs here, it's natural we do so during re-create as well. Updates #16424 --- agent/agent_test.go | 183 ++++++++++++++++-- agent/agentcontainers/api.go | 69 ++++++- agent/agentcontainers/api_test.go | 2 +- agent/agentcontainers/devcontainercli.go | 30 ++- agent/agentcontainers/devcontainercli_test.go | 39 ++++ agent/api.go | 7 +- codersdk/workspacesdk/agentconn.go | 16 ++ 7 files changed, 321 insertions(+), 25 deletions(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index fe2c99059e9d8..38688c6f72792 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1935,8 +1935,6 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") } - ctx := testutil.Context(t, testutil.WaitLong) - pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") ct, err := pool.RunWithOptions(&dockertest.RunOptions{ @@ -1948,10 +1946,10 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { config.RestartPolicy = docker.RestartPolicy{Name: "no"} }) require.NoError(t, err, "Could not start container") - t.Cleanup(func() { + defer func() { err := pool.Purge(ct) require.NoError(t, err, "Could not stop container") - }) + }() // Wait for container to start require.Eventually(t, func() bool { ct, ok := pool.ContainerByName(ct.Container.Name) @@ -1962,6 +1960,7 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { o.ExperimentalDevcontainersEnabled = true }) + ctx := testutil.Context(t, testutil.WaitLong) ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) { arp.Container = ct.Container.ID }) @@ -2005,9 +2004,6 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") } - ctx := testutil.Context(t, testutil.WaitLong) - - // Connect to Docker pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") @@ -2051,7 +2047,7 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { }, }, } - // nolint: dogsled + //nolint:dogsled conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { o.ExperimentalDevcontainersEnabled = true }) @@ -2079,8 +2075,7 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { return false }, testutil.WaitSuperLong, testutil.IntervalMedium, "no container with workspace folder label found") - - t.Cleanup(func() { + defer func() { // We can't rely on pool here because the container is not // managed by it (it is managed by @devcontainer/cli). err := pool.Client.RemoveContainer(docker.RemoveContainerOptions{ @@ -2089,13 +2084,15 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { Force: true, }) assert.NoError(t, err, "remove container") - }) + }() containerInfo, err := pool.Client.InspectContainer(container.ID) require.NoError(t, err, "inspect container") t.Logf("Container state: status: %v", containerInfo.State.Status) require.True(t, containerInfo.State.Running, "container should be running") + ctx := testutil.Context(t, testutil.WaitLong) + ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "", func(opts *workspacesdk.AgentReconnectingPTYInit) { opts.Container = container.ID }) @@ -2124,6 +2121,170 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { require.NoError(t, err, "file should exist outside devcontainer") } +// TestAgent_DevcontainerRecreate tests that RecreateDevcontainer +// recreates a devcontainer and emits logs. +// +// This tests end-to-end functionality of auto-starting a devcontainer. +// It runs "devcontainer up" which creates a real Docker container. As +// such, it does not run by default in CI. +// +// You can run it manually as follows: +// +// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_DevcontainerRecreate +func TestAgent_DevcontainerRecreate(t *testing.T) { + if os.Getenv("CODER_TEST_USE_DOCKER") != "1" { + t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + + // Prepare temporary devcontainer for test (mywork). + devcontainerID := uuid.New() + devcontainerLogSourceID := uuid.New() + workspaceFolder := filepath.Join(t.TempDir(), "mywork") + t.Logf("Workspace folder: %s", workspaceFolder) + devcontainerPath := filepath.Join(workspaceFolder, ".devcontainer") + err = os.MkdirAll(devcontainerPath, 0o755) + require.NoError(t, err, "create devcontainer directory") + devcontainerFile := filepath.Join(devcontainerPath, "devcontainer.json") + err = os.WriteFile(devcontainerFile, []byte(`{ + "name": "mywork", + "image": "busybox:latest", + "cmd": ["sleep", "infinity"] + }`), 0o600) + require.NoError(t, err, "write devcontainer.json") + + manifest := agentsdk.Manifest{ + // Set up pre-conditions for auto-starting a devcontainer, the + // script is used to extract the log source ID. + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: devcontainerID, + Name: "test", + WorkspaceFolder: workspaceFolder, + }, + }, + Scripts: []codersdk.WorkspaceAgentScript{ + { + ID: devcontainerID, + LogSourceID: devcontainerLogSourceID, + }, + }, + } + + //nolint:dogsled + conn, client, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { + o.ExperimentalDevcontainersEnabled = true + }) + + // We enabled autostart for the devcontainer, so ready is a good + // indication that the devcontainer is up and running. Importantly, + // this also means that the devcontainer startup is no longer + // producing logs that may interfere with the recreate logs. + require.Eventually(t, func() bool { + states := client.GetLifecycleStates() + return slices.Contains(states, codersdk.WorkspaceAgentLifecycleReady) + }, testutil.WaitLong, testutil.IntervalMedium, "devcontainer not ready") + + t.Logf("Lookging for container with label: devcontainer.local_folder=%s", workspaceFolder) + + var container docker.APIContainers + require.Eventually(t, func() bool { + containers, err := pool.Client.ListContainers(docker.ListContainersOptions{All: true}) + if err != nil { + t.Logf("Error listing containers: %v", err) + return false + } + for _, c := range containers { + t.Logf("Found container: %s with labels: %v", c.ID[:12], c.Labels) + if v, ok := c.Labels["devcontainer.local_folder"]; ok && v == workspaceFolder { + t.Logf("Found matching container: %s", c.ID[:12]) + container = c + return true + } + } + return false + }, testutil.WaitLong, testutil.IntervalMedium, "no container with workspace folder label found") + defer func(container docker.APIContainers) { + // We can't rely on pool here because the container is not + // managed by it (it is managed by @devcontainer/cli). + err := pool.Client.RemoveContainer(docker.RemoveContainerOptions{ + ID: container.ID, + RemoveVolumes: true, + Force: true, + }) + assert.Error(t, err, "container should be removed by recreate") + }(container) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Capture logs via ScriptLogger. + logsCh := make(chan *proto.BatchCreateLogsRequest, 1) + client.SetLogsChannel(logsCh) + + // Invoke recreate to trigger the destruction and recreation of the + // devcontainer, we do it in a goroutine so we can process logs + // concurrently. + go func(container docker.APIContainers) { + err := conn.RecreateDevcontainer(ctx, container.ID) + assert.NoError(t, err, "recreate devcontainer should succeed") + }(container) + + t.Logf("Checking recreate logs for outcome...") + + // Wait for the logs to be emitted, the @devcontainer/cli up command + // will emit a log with the outcome at the end suggesting we did + // receive all the logs. +waitForOutcomeLoop: + for { + batch := testutil.RequireReceive(ctx, t, logsCh) + + if bytes.Equal(batch.LogSourceId, devcontainerLogSourceID[:]) { + for _, log := range batch.Logs { + t.Logf("Received log: %s", log.Output) + if strings.Contains(log.Output, "\"outcome\"") { + break waitForOutcomeLoop + } + } + } + } + + t.Logf("Checking there's a new container with label: devcontainer.local_folder=%s", workspaceFolder) + + // Make sure the container exists and isn't the same as the old one. + require.Eventually(t, func() bool { + containers, err := pool.Client.ListContainers(docker.ListContainersOptions{All: true}) + if err != nil { + t.Logf("Error listing containers: %v", err) + return false + } + for _, c := range containers { + t.Logf("Found container: %s with labels: %v", c.ID[:12], c.Labels) + if v, ok := c.Labels["devcontainer.local_folder"]; ok && v == workspaceFolder { + if c.ID == container.ID { + t.Logf("Found same container: %s", c.ID[:12]) + return false + } + t.Logf("Found new container: %s", c.ID[:12]) + container = c + return true + } + } + return false + }, testutil.WaitLong, testutil.IntervalMedium, "new devcontainer not found") + defer func(container docker.APIContainers) { + // We can't rely on pool here because the container is not + // managed by it (it is managed by @devcontainer/cli). + err := pool.Client.RemoveContainer(docker.RemoveContainerOptions{ + ID: container.ID, + RemoveVolumes: true, + Force: true, + }) + assert.NoError(t, err, "remove container") + }(container) +} + func TestAgent_Dial(t *testing.T) { t.Parallel() diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 9ecf70a4681a1..74d4f76c8fec6 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/quartz" ) @@ -43,6 +44,7 @@ type API struct { cl Lister dccli DevcontainerCLI clock quartz.Clock + scriptLogger func(logSourceID uuid.UUID) ScriptLogger // lockCh protects the below fields. We use a channel instead of a // mutex so we can handle cancellation properly. @@ -52,6 +54,8 @@ type API struct { 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. + + devcontainerLogSourceIDs map[string]uuid.UUID // Track devcontainer log source IDs. } // Option is a functional option for API. @@ -91,13 +95,23 @@ func WithDevcontainerCLI(dccli DevcontainerCLI) Option { // WithDevcontainers sets the known devcontainers for the API. This // allows the API to be aware of devcontainers defined in the workspace // agent manifest. -func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer) Option { +func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer, scripts []codersdk.WorkspaceAgentScript) Option { return func(api *API) { - if len(devcontainers) > 0 { - api.knownDevcontainers = slices.Clone(devcontainers) - api.devcontainerNames = make(map[string]struct{}, len(devcontainers)) - for _, devcontainer := range devcontainers { - api.devcontainerNames[devcontainer.Name] = struct{}{} + if len(devcontainers) == 0 { + return + } + api.knownDevcontainers = slices.Clone(devcontainers) + api.devcontainerNames = make(map[string]struct{}, len(devcontainers)) + api.devcontainerLogSourceIDs = make(map[string]uuid.UUID) + for _, devcontainer := range devcontainers { + api.devcontainerNames[devcontainer.Name] = struct{}{} + for _, script := range scripts { + // The devcontainer scripts match the devcontainer ID for + // identification. + if script.ID == devcontainer.ID { + api.devcontainerLogSourceIDs[devcontainer.WorkspaceFolder] = script.LogSourceID + break + } } } } @@ -112,6 +126,27 @@ func WithWatcher(w watcher.Watcher) Option { } } +// ScriptLogger is an interface for sending devcontainer logs to the +// controlplane. +type ScriptLogger interface { + Send(ctx context.Context, log ...agentsdk.Log) error + Flush(context.Context) error +} + +// noopScriptLogger is a no-op implementation of the ScriptLogger +// interface. +type noopScriptLogger struct{} + +func (noopScriptLogger) Send(ctx context.Context, log ...agentsdk.Log) error { return nil } +func (noopScriptLogger) Flush(ctx context.Context) error { return nil } + +// WithScriptLogger sets the script logger provider for devcontainer operations. +func WithScriptLogger(scriptLogger func(logSourceID uuid.UUID) ScriptLogger) Option { + return func(api *API) { + api.scriptLogger = scriptLogger + } +} + // NewAPI returns a new API with the given options applied. func NewAPI(logger slog.Logger, options ...Option) *API { ctx, cancel := context.WithCancel(context.Background()) @@ -127,6 +162,7 @@ func NewAPI(logger slog.Logger, options ...Option) *API { devcontainerNames: make(map[string]struct{}), knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{}, configFileModifiedTimes: make(map[string]time.Time), + scriptLogger: func(uuid.UUID) ScriptLogger { return noopScriptLogger{} }, } for _, opt := range options { opt(api) @@ -426,7 +462,26 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques return } - _, err = api.dccli.Up(ctx, workspaceFolder, configPath, WithRemoveExistingContainer()) + // Send logs via agent logging facilities. + logSourceID := api.devcontainerLogSourceIDs[workspaceFolder] + if logSourceID == uuid.Nil { + // Fallback to the external log source ID if not found. + logSourceID = agentsdk.ExternalLogSourceID + } + scriptLogger := api.scriptLogger(logSourceID) + defer func() { + flushCtx, cancel := context.WithTimeout(api.ctx, 5*time.Second) + defer cancel() + if err := scriptLogger.Flush(flushCtx); err != nil { + api.logger.Error(flushCtx, "flush devcontainer logs failed", slog.Error(err)) + } + }() + infoW := agentsdk.LogsWriter(ctx, scriptLogger.Send, logSourceID, codersdk.LogLevelInfo) + defer infoW.Close() + errW := agentsdk.LogsWriter(ctx, scriptLogger.Send, logSourceID, codersdk.LogLevelError) + defer errW.Close() + + _, err = api.dccli.Up(ctx, workspaceFolder, configPath, WithOutput(infoW, errW), WithRemoveExistingContainer()) if err != nil { httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ Message: "Could not recreate devcontainer", diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index d6cac1cd2a97e..9e31e385ca770 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -564,7 +564,7 @@ func TestAPI(t *testing.T) { } if len(tt.knownDevcontainers) > 0 { - apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers)) + apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers, nil)) } api := agentcontainers.NewAPI(logger, apiOptions...) diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index d6060f862cb40..7e3122b182fdb 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -31,8 +31,18 @@ func WithRemoveExistingContainer() DevcontainerCLIUpOptions { } } +// WithOutput sets stdout and stderr writers for Up command logs. +func WithOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions { + return func(o *devcontainerCLIUpConfig) { + o.stdout = stdout + o.stderr = stderr + } +} + type devcontainerCLIUpConfig struct { removeExistingContainer bool + stdout io.Writer + stderr io.Writer } func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { @@ -78,18 +88,28 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st } cmd := d.execer.CommandContext(ctx, "devcontainer", args...) - var stdout bytes.Buffer - cmd.Stdout = io.MultiWriter(&stdout, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}) - cmd.Stderr = &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))} + // Capture stdout for parsing and stream logs for both default and provided writers. + var stdoutBuf bytes.Buffer + stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} + if conf.stdout != nil { + stdoutWriters = append(stdoutWriters, conf.stdout) + } + cmd.Stdout = io.MultiWriter(stdoutWriters...) + // Stream stderr logs and provided writer if any. + stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} + if conf.stderr != nil { + stderrWriters = append(stderrWriters, conf.stderr) + } + cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdout.Bytes()); err2 != nil { + if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()); err2 != nil { err = errors.Join(err, err2) } return "", err } - result, err := parseDevcontainerCLILastLine(ctx, logger, stdout.Bytes()) + result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()) if err != nil { return "", err } diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index d768b997cc1e1..cdba0211ab94e 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -128,6 +128,45 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { }) } +// TestDevcontainerCLI_WithOutput tests that WithOutput captures CLI +// logs to provided writers. +func TestDevcontainerCLI_WithOutput(t *testing.T) { + t.Parallel() + + // Prepare test executable and logger. + testExePath, err := os.Executable() + require.NoError(t, err, "get test executable path") + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + ctx := testutil.Context(t, testutil.WaitMedium) + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution with a standard up.log file. + wantArgs := "up --log-format json --workspace-folder /test/workspace" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"), + } + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + + // Call Up with WithOutput to capture CLI logs. + containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithOutput(outBuf, errBuf)) + require.NoError(t, err, "Up should succeed") + require.NotEmpty(t, containerID, "expected non-empty container ID") + + // Read expected log content. + expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log")) + require.NoError(t, err, "reading expected log file") + + // Verify stdout buffer contains the CLI logs and stderr is empty. + assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") +} + // testDevcontainerExecer implements the agentexec.Execer interface for testing. type testDevcontainerExecer struct { testExePath string diff --git a/agent/api.go b/agent/api.go index f09d39b172bd5..2e15530adc608 100644 --- a/agent/api.go +++ b/agent/api.go @@ -7,6 +7,8 @@ import ( "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" @@ -40,12 +42,15 @@ func (a *agent) apiHandler() (http.Handler, func() error) { if a.experimentalDevcontainersEnabled { containerAPIOpts := []agentcontainers.Option{ agentcontainers.WithExecer(a.execer), + agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger { + return a.logSender.GetScriptLogger(logSourceID) + }), } manifest := a.manifest.Load() if manifest != nil && len(manifest.Devcontainers) > 0 { containerAPIOpts = append( containerAPIOpts, - agentcontainers.WithDevcontainers(manifest.Devcontainers), + agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts), ) } diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 97b4268c68780..f3c68d38b5575 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -387,6 +387,22 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent return resp, json.NewDecoder(res.Body).Decode(&resp) } +// RecreateDevcontainer recreates a devcontainer with the given container. +// This is a blocking call and will wait for the container to be recreated. +func (c *AgentConn) RecreateDevcontainer(ctx context.Context, containerIDOrName string) error { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/container/"+containerIDOrName+"/recreate", nil) + if err != nil { + return xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return codersdk.ReadBodyAsError(res) + } + return nil +} + // apiRequest makes a request to the workspace agent's HTTP API server. func (c *AgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { ctx, span := tracing.StartSpan(ctx) From 5a74d6f4f4ac55163c1df2dc77e5ed7e49cc9db6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 15 May 2025 08:43:09 +0000 Subject: [PATCH 2/6] fix typo --- agent/agent_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index 38688c6f72792..66b9e840200b5 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2187,7 +2187,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { return slices.Contains(states, codersdk.WorkspaceAgentLifecycleReady) }, testutil.WaitLong, testutil.IntervalMedium, "devcontainer not ready") - t.Logf("Lookging for container with label: devcontainer.local_folder=%s", workspaceFolder) + t.Logf("Looking for container with label: devcontainer.local_folder=%s", workspaceFolder) var container docker.APIContainers require.Eventually(t, func() bool { From 7d9d1615913e2b863a64c3ec7cce5f0b2a6c8a1c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 15 May 2025 11:15:39 +0000 Subject: [PATCH 3/6] testutil.Eventually --- agent/agent_test.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index 66b9e840200b5..3e903d50717d9 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2178,19 +2178,21 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { o.ExperimentalDevcontainersEnabled = true }) + ctx := testutil.Context(t, testutil.WaitLong) + // We enabled autostart for the devcontainer, so ready is a good // indication that the devcontainer is up and running. Importantly, // this also means that the devcontainer startup is no longer // producing logs that may interfere with the recreate logs. - require.Eventually(t, func() bool { + testutil.Eventually(ctx, t, func(context.Context) bool { states := client.GetLifecycleStates() return slices.Contains(states, codersdk.WorkspaceAgentLifecycleReady) - }, testutil.WaitLong, testutil.IntervalMedium, "devcontainer not ready") + }, testutil.IntervalMedium, "devcontainer not ready") t.Logf("Looking for container with label: devcontainer.local_folder=%s", workspaceFolder) var container docker.APIContainers - require.Eventually(t, func() bool { + testutil.Eventually(ctx, t, func(context.Context) bool { containers, err := pool.Client.ListContainers(docker.ListContainersOptions{All: true}) if err != nil { t.Logf("Error listing containers: %v", err) @@ -2205,7 +2207,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { } } return false - }, testutil.WaitLong, testutil.IntervalMedium, "no container with workspace folder label found") + }, testutil.IntervalMedium, "no container with workspace folder label found") defer func(container docker.APIContainers) { // We can't rely on pool here because the container is not // managed by it (it is managed by @devcontainer/cli). @@ -2217,7 +2219,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { assert.Error(t, err, "container should be removed by recreate") }(container) - ctx := testutil.Context(t, testutil.WaitLong) + ctx = testutil.Context(t, testutil.WaitLong) // Reset context. // Capture logs via ScriptLogger. logsCh := make(chan *proto.BatchCreateLogsRequest, 1) @@ -2253,7 +2255,7 @@ waitForOutcomeLoop: t.Logf("Checking there's a new container with label: devcontainer.local_folder=%s", workspaceFolder) // Make sure the container exists and isn't the same as the old one. - require.Eventually(t, func() bool { + testutil.Eventually(ctx, t, func(context.Context) bool { containers, err := pool.Client.ListContainers(docker.ListContainersOptions{All: true}) if err != nil { t.Logf("Error listing containers: %v", err) @@ -2272,7 +2274,7 @@ waitForOutcomeLoop: } } return false - }, testutil.WaitLong, testutil.IntervalMedium, "new devcontainer not found") + }, testutil.IntervalMedium, "new devcontainer not found") defer func(container docker.APIContainers) { // We can't rely on pool here because the container is not // managed by it (it is managed by @devcontainer/cli). From abdd813fbabea04be3600f09c53979a3f6b6bc1a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 15 May 2025 11:20:53 +0000 Subject: [PATCH 4/6] log --- agent/agentcontainers/api.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 74d4f76c8fec6..3bf8e23411a22 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -113,6 +113,13 @@ func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer, scri break } } + if api.devcontainerLogSourceIDs[devcontainer.WorkspaceFolder] == uuid.Nil { + api.logger.Error(api.ctx, "devcontainer log source ID not found for devcontainer", + slog.F("devcontainer", devcontainer.Name), + slog.F("workspace_folder", devcontainer.WorkspaceFolder), + slog.F("config_path", devcontainer.ConfigPath), + ) + } } } } @@ -164,6 +171,8 @@ func NewAPI(logger slog.Logger, options ...Option) *API { configFileModifiedTimes: make(map[string]time.Time), scriptLogger: func(uuid.UUID) ScriptLogger { return noopScriptLogger{} }, } + // The ctx and logger must be set before applying options to avoid + // nil pointer dereference. for _, opt := range options { opt(api) } From 4c71a6cb5952005256325e8be0aeccfdf4b252c2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 15 May 2025 11:31:37 +0000 Subject: [PATCH 5/6] fix test --- agent/agentcontainers/api_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 9e31e385ca770..2c602de5cff3a 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -563,8 +563,17 @@ func TestAPI(t *testing.T) { agentcontainers.WithWatcher(watcher.NewNoop()), } + // Generate matching scripts for the known devcontainers + // (required to extract log source ID). + var scripts []codersdk.WorkspaceAgentScript + for i := range tt.knownDevcontainers { + scripts = append(scripts, codersdk.WorkspaceAgentScript{ + ID: tt.knownDevcontainers[i].ID, + LogSourceID: uuid.New(), + }) + } if len(tt.knownDevcontainers) > 0 { - apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers, nil)) + apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers, scripts)) } api := agentcontainers.NewAPI(logger, apiOptions...) From d9cd9889c62bc1a713dda6ec7a02d44b247e844f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 15 May 2025 11:54:06 +0000 Subject: [PATCH 6/6] fix lint --- agent/agent_test.go | 1 + agent/agentcontainers/api.go | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index 3e903d50717d9..741d245ec3f6f 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2135,6 +2135,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { if os.Getenv("CODER_TEST_USE_DOCKER") != "1" { t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") } + t.Parallel() pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 3bf8e23411a22..c3393c3fdec9e 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -137,15 +137,15 @@ func WithWatcher(w watcher.Watcher) Option { // controlplane. type ScriptLogger interface { Send(ctx context.Context, log ...agentsdk.Log) error - Flush(context.Context) error + Flush(ctx context.Context) error } // noopScriptLogger is a no-op implementation of the ScriptLogger // interface. type noopScriptLogger struct{} -func (noopScriptLogger) Send(ctx context.Context, log ...agentsdk.Log) error { return nil } -func (noopScriptLogger) Flush(ctx context.Context) error { return nil } +func (noopScriptLogger) Send(context.Context, ...agentsdk.Log) error { return nil } +func (noopScriptLogger) Flush(context.Context) error { return nil } // WithScriptLogger sets the script logger provider for devcontainer operations. func WithScriptLogger(scriptLogger func(logSourceID uuid.UUID) ScriptLogger) Option { 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