From ea9d2105cbb18a33be7a521a35e21caabc41a47d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 11:23:26 +0000 Subject: [PATCH 1/8] feat(cli): improve devcontainer support for `coder show` Fixes coder/internal#747 --- cli/cliui/resources.go | 155 ++++++-- cli/show.go | 10 + cli/show_test.go | 358 ++++++++++++++++++ .../error_devcontainer.golden | 9 + .../long_error_message.golden | 9 + .../long_error_message_with_detail.golden | 9 + .../mixed_devcontainer_states.golden | 13 + .../running_devcontainer_with_agent.golden | 11 + ...g_devcontainer_with_agent_and_error.golden | 11 + .../running_devcontainer_without_agent.golden | 8 + .../starting_devcontainer.golden | 8 + .../stopped_devcontainer.golden | 8 + cli/testdata/coder_show_--help.golden | 6 +- 13 files changed, 586 insertions(+), 29 deletions(-) create mode 100644 cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/long_error_message_with_detail.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/mixed_devcontainer_states.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent_and_error.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_without_agent.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/starting_devcontainer.golden create mode 100644 cli/testdata/TestShowDevcontainers_Golden/stopped_devcontainer.golden diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index be112ea177200..cf0c7474fea18 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -12,6 +12,7 @@ import ( "golang.org/x/mod/semver" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/pretty" ) @@ -29,6 +30,7 @@ type WorkspaceResourcesOptions struct { ServerVersion string ListeningPorts map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse Devcontainers map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse + ShowDetails bool } // WorkspaceResources displays the connection status and tree-view of provided resources. @@ -69,7 +71,11 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource totalAgents := 0 for _, resource := range resources { - totalAgents += len(resource.Agents) + for _, agent := range resource.Agents { + if !agent.ParentID.Valid { + totalAgents++ + } + } } for _, resource := range resources { @@ -94,12 +100,15 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource "", }) // Display all agents associated with the resource. - for index, agent := range resource.Agents { + agents := slice.Filter(resource.Agents, func(agent codersdk.WorkspaceAgent) bool { + return !agent.ParentID.Valid + }) + for index, agent := range agents { tableWriter.AppendRow(renderAgentRow(agent, index, totalAgents, options)) for _, row := range renderListeningPorts(options, agent.ID, index, totalAgents) { tableWriter.AppendRow(row) } - for _, row := range renderDevcontainers(options, agent.ID, index, totalAgents) { + for _, row := range renderDevcontainers(resources, options, agent.ID, index, totalAgents) { tableWriter.AppendRow(row) } } @@ -125,7 +134,7 @@ func renderAgentRow(agent codersdk.WorkspaceAgent, index, totalAgents int, optio } if !options.HideAccess { sshCommand := "coder ssh " + options.WorkspaceName - if totalAgents > 1 { + if totalAgents > 1 || len(options.Devcontainers) > 0 { sshCommand += "." + agent.Name } sshCommand = pretty.Sprint(DefaultStyles.Code, sshCommand) @@ -164,45 +173,135 @@ func renderPortRow(port codersdk.WorkspaceAgentListeningPort, idx, total int) ta return table.Row{sb.String()} } -func renderDevcontainers(wro WorkspaceResourcesOptions, agentID uuid.UUID, index, totalAgents int) []table.Row { +func renderDevcontainers(resources []codersdk.WorkspaceResource, wro WorkspaceResourcesOptions, agentID uuid.UUID, index, totalAgents int) []table.Row { var rows []table.Row if wro.Devcontainers == nil { return []table.Row{} } dc, ok := wro.Devcontainers[agentID] - if !ok || len(dc.Containers) == 0 { + if !ok || len(dc.Devcontainers) == 0 { return []table.Row{} } rows = append(rows, table.Row{ fmt.Sprintf(" %s─ %s", renderPipe(index, totalAgents), "Devcontainers"), }) - for idx, container := range dc.Containers { - rows = append(rows, renderDevcontainerRow(container, idx, len(dc.Containers))) + for idx, devcontainer := range dc.Devcontainers { + rows = append(rows, renderDevcontainerRow(resources, devcontainer, idx, len(dc.Devcontainers), wro)...) } return rows } -func renderDevcontainerRow(container codersdk.WorkspaceAgentContainer, index, total int) table.Row { - var row table.Row - var sb strings.Builder - _, _ = sb.WriteString(" ") - _, _ = sb.WriteString(renderPipe(index, total)) - _, _ = sb.WriteString("─ ") - _, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Code, "%s", container.FriendlyName)) - row = append(row, sb.String()) - sb.Reset() - if container.Running { - _, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Keyword, "(%s)", container.Status)) - } else { - _, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Error, "(%s)", container.Status)) +func renderDevcontainerRow(resources []codersdk.WorkspaceResource, devcontainer codersdk.WorkspaceAgentDevcontainer, index, total int, wro WorkspaceResourcesOptions) []table.Row { + var rows []table.Row + + // If the devcontainer is running and has an associated agent, we want to + // display the agent's details. Otherwise, we just display the devcontainer + // name and status. + var subAgent *codersdk.WorkspaceAgent + displayName := devcontainer.Name + if devcontainer.Agent != nil && devcontainer.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning { + for _, resource := range resources { + for _, agent := range resource.Agents { + if agent.ID == devcontainer.Agent.ID { + subAgent = &agent + break + } + } + if subAgent != nil { + break + } + } + + displayName = devcontainer.Agent.Name + if subAgent != nil { + displayName += fmt.Sprintf(" (%s, %s)", subAgent.OperatingSystem, subAgent.Architecture) + } else { + displayName += " (linux, amd64)" + } + } + + if devcontainer.Container != nil { + displayName += " " + pretty.Sprint(DefaultStyles.Keyword, "["+devcontainer.Container.FriendlyName+"]") + } + + // Build the main row. + row := table.Row{ + fmt.Sprintf(" %s─ %s", renderPipe(index, total), displayName), + } + + // Add status, health, and version columns. + if !wro.HideAgentState { + if subAgent != nil { + row = append(row, renderAgentStatus(*subAgent)) + row = append(row, renderAgentHealth(*subAgent)) + row = append(row, renderAgentVersion(subAgent.Version, wro.ServerVersion)) + } else { + row = append(row, renderDevcontainerStatus(devcontainer.Status)) + row = append(row, "") // No health for devcontainer without agent. + row = append(row, "") // No version for devcontainer without agent. + } + } + + // Add access column. + if !wro.HideAccess { + if subAgent != nil { + accessString := fmt.Sprintf("coder ssh %s.%s", wro.WorkspaceName, subAgent.Name) + row = append(row, pretty.Sprint(DefaultStyles.Code, accessString)) + } else { + row = append(row, "") // No access for devcontainers without agent. + } + } + + rows = append(rows, row) + + // Add error message if present. + if errorMessage := devcontainer.Error; errorMessage != "" { + // Cap error message length for display. + if !wro.ShowDetails && len(errorMessage) > 80 { + errorMessage = errorMessage[:77] + "..." + } + errorRow := table.Row{ + " × " + pretty.Sprint(DefaultStyles.Error, errorMessage), + "", + "", + "", + } + if !wro.HideAccess { + errorRow = append(errorRow, "") + } + rows = append(rows, errorRow) + } + + // Add listening ports for the devcontainer agent. + if subAgent != nil { + portRows := renderListeningPorts(wro, subAgent.ID, index, total) + for _, portRow := range portRows { + // Adjust indentation for ports under devcontainer agent. + if len(portRow) > 0 { + if str, ok := portRow[0].(string); ok { + portRow[0] = " " + str // Add extra indentation. + } + } + rows = append(rows, portRow) + } + } + + return rows +} + +func renderDevcontainerStatus(status codersdk.WorkspaceAgentDevcontainerStatus) string { + switch status { + case codersdk.WorkspaceAgentDevcontainerStatusRunning: + return pretty.Sprint(DefaultStyles.Keyword, "▶ running") + case codersdk.WorkspaceAgentDevcontainerStatusStopped: + return pretty.Sprint(DefaultStyles.Placeholder, "⏹ stopped") + case codersdk.WorkspaceAgentDevcontainerStatusStarting: + return pretty.Sprint(DefaultStyles.Warn, "⧗ starting") + case codersdk.WorkspaceAgentDevcontainerStatusError: + return pretty.Sprint(DefaultStyles.Error, "✘ error") + default: + return pretty.Sprint(DefaultStyles.Placeholder, "○ "+string(status)) } - row = append(row, sb.String()) - sb.Reset() - // "health" is not applicable here. - row = append(row, sb.String()) - _, _ = sb.WriteString(container.Image) - row = append(row, sb.String()) - return row } func renderAgentStatus(agent codersdk.WorkspaceAgent) string { diff --git a/cli/show.go b/cli/show.go index f2d3df3ecc3c5..b5a61c878976d 100644 --- a/cli/show.go +++ b/cli/show.go @@ -15,9 +15,17 @@ import ( func (r *RootCmd) show() *serpent.Command { client := new(codersdk.Client) + var details bool return &serpent.Command{ Use: "show ", Short: "Display details of a workspace's resources and agents", + Options: serpent.OptionSet{ + { + Flag: "details", + Description: "Show full error messages and additional details.", + Value: serpent.BoolOf(&details), + }, + }, Middleware: serpent.Chain( serpent.RequireNArgs(1), r.InitClient(client), @@ -35,6 +43,7 @@ func (r *RootCmd) show() *serpent.Command { options := cliui.WorkspaceResourcesOptions{ WorkspaceName: workspace.Name, ServerVersion: buildInfo.Version, + ShowDetails: details, } if workspace.LatestBuild.Status == codersdk.WorkspaceStatusRunning { // Get listening ports for each agent. @@ -42,6 +51,7 @@ func (r *RootCmd) show() *serpent.Command { options.ListeningPorts = ports options.Devcontainers = devcontainers } + return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, options) }, } diff --git a/cli/show_test.go b/cli/show_test.go index 7191898f8c0ec..36a5824174fc4 100644 --- a/cli/show_test.go +++ b/cli/show_test.go @@ -1,12 +1,19 @@ package cli_test import ( + "bytes" "testing" + "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" ) @@ -53,3 +60,354 @@ func TestShow(t *testing.T) { <-doneChan }) } + +func TestShowDevcontainers_Golden(t *testing.T) { + t.Parallel() + + mainAgentID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + agentID := mainAgentID + + testCases := []struct { + name string + showDetails bool + devcontainers []codersdk.WorkspaceAgentDevcontainer + listeningPorts map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse + }{ + { + name: "running_devcontainer_with_agent", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "web-dev", + WorkspaceFolder: "/workspaces/web-dev", + ConfigPath: "/workspaces/web-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Dirty: false, + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-web-dev", + FriendlyName: "quirky_lovelace", + Image: "mcr.microsoft.com/devcontainers/typescript-node:1.0.0", + Running: true, + Status: "running", + CreatedAt: time.Now().Add(-1 * time.Hour), + Labels: map[string]string{ + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/web-dev/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/web-dev", + }, + }, + Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ + ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), + Name: "web-dev", + Directory: "/workspaces/web-dev", + }, + }, + }, + listeningPorts: map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse{ + uuid.MustParse("22222222-2222-2222-2222-222222222222"): { + Ports: []codersdk.WorkspaceAgentListeningPort{ + { + ProcessName: "node", + Network: "tcp", + Port: 3000, + }, + { + ProcessName: "webpack-dev-server", + Network: "tcp", + Port: 8080, + }, + }, + }, + }, + }, + { + name: "running_devcontainer_without_agent", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), + Name: "web-server", + WorkspaceFolder: "/workspaces/web-server", + ConfigPath: "/workspaces/web-server/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Dirty: false, + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-web-server", + FriendlyName: "amazing_turing", + Image: "nginx:latest", + Running: true, + Status: "running", + CreatedAt: time.Now().Add(-30 * time.Minute), + Labels: map[string]string{ + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/web-server/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/web-server", + }, + }, + Agent: nil, // No agent for this running container. + }, + }, + }, + { + name: "stopped_devcontainer", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), + Name: "api-dev", + WorkspaceFolder: "/workspaces/api-dev", + ConfigPath: "/workspaces/api-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + Dirty: false, + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-api-dev", + FriendlyName: "clever_darwin", + Image: "mcr.microsoft.com/devcontainers/go:1.0.0", + Running: false, + Status: "exited", + CreatedAt: time.Now().Add(-2 * time.Hour), + Labels: map[string]string{ + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/api-dev/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/api-dev", + }, + }, + Agent: nil, // No agent for stopped container. + }, + }, + }, + { + name: "starting_devcontainer", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("55555555-5555-5555-5555-555555555555"), + Name: "database-dev", + WorkspaceFolder: "/workspaces/database-dev", + ConfigPath: "/workspaces/database-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStarting, + Dirty: false, + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-database-dev", + FriendlyName: "nostalgic_hawking", + Image: "mcr.microsoft.com/devcontainers/postgres:1.0.0", + Running: false, + Status: "created", + CreatedAt: time.Now().Add(-5 * time.Minute), + Labels: map[string]string{ + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/database-dev/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/database-dev", + }, + }, + Agent: nil, // No agent yet while starting. + }, + }, + }, + { + name: "error_devcontainer", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("66666666-6666-6666-6666-666666666666"), + Name: "failed-dev", + WorkspaceFolder: "/workspaces/failed-dev", + ConfigPath: "/workspaces/failed-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusError, + Dirty: false, + Error: "Failed to pull image mcr.microsoft.com/devcontainers/go:latest: timeout after 5m0s", + Container: nil, // No container due to error. + Agent: nil, // No agent due to error. + }, + }, + }, + + { + name: "mixed_devcontainer_states", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + Name: "frontend", + WorkspaceFolder: "/workspaces/frontend", + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-frontend", + FriendlyName: "vibrant_tesla", + Image: "node:18", + Running: true, + Status: "running", + CreatedAt: time.Now().Add(-30 * time.Minute), + }, + Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ + ID: uuid.MustParse("99999999-9999-9999-9999-999999999999"), + Name: "frontend", + Directory: "/workspaces/frontend", + }, + }, + { + ID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + Name: "backend", + WorkspaceFolder: "/workspaces/backend", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-backend", + FriendlyName: "peaceful_curie", + Image: "python:3.11", + Running: false, + Status: "exited", + CreatedAt: time.Now().Add(-1 * time.Hour), + }, + Agent: nil, + }, + { + ID: uuid.MustParse("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"), + Name: "error-container", + WorkspaceFolder: "/workspaces/error-container", + Status: codersdk.WorkspaceAgentDevcontainerStatusError, + Error: "Container build failed: dockerfile syntax error on line 15", + Container: nil, + Agent: nil, + }, + }, + listeningPorts: map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse{ + uuid.MustParse("99999999-9999-9999-9999-999999999999"): { + Ports: []codersdk.WorkspaceAgentListeningPort{ + { + ProcessName: "vite", + Network: "tcp", + Port: 5173, + }, + }, + }, + }, + }, + { + name: "running_devcontainer_with_agent_and_error", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("cccccccc-dddd-eeee-ffff-000000000000"), + Name: "problematic-dev", + WorkspaceFolder: "/workspaces/problematic-dev", + ConfigPath: "/workspaces/problematic-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Dirty: false, + Error: "Warning: Container started but healthcheck failed", + Container: &codersdk.WorkspaceAgentContainer{ + ID: "container-problematic", + FriendlyName: "cranky_mendel", + Image: "mcr.microsoft.com/devcontainers/python:1.0.0", + Running: true, + Status: "running", + CreatedAt: time.Now().Add(-15 * time.Minute), + Labels: map[string]string{ + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/problematic-dev/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/problematic-dev", + }, + }, + Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ + ID: uuid.MustParse("dddddddd-eeee-ffff-aaaa-111111111111"), + Name: "problematic-dev", + Directory: "/workspaces/problematic-dev", + }, + }, + }, + listeningPorts: map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse{ + uuid.MustParse("dddddddd-eeee-ffff-aaaa-111111111111"): { + Ports: []codersdk.WorkspaceAgentListeningPort{ + { + ProcessName: "python", + Network: "tcp", + Port: 8000, + }, + }, + }, + }, + }, + { + name: "long_error_message", + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("eeeeeeee-ffff-0000-1111-222222222222"), + Name: "long-error-dev", + WorkspaceFolder: "/workspaces/long-error-dev", + ConfigPath: "/workspaces/long-error-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusError, + Dirty: false, + Error: "Failed to build devcontainer: dockerfile parse error at line 25: unknown instruction 'INSTALL', did you mean 'RUN apt-get install'? This is a very long error message that should be truncated when detail flag is not used", + Container: nil, + Agent: nil, + }, + }, + }, + { + name: "long_error_message_with_detail", + showDetails: true, + devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + ID: uuid.MustParse("eeeeeeee-ffff-0000-1111-222222222222"), + Name: "long-error-dev", + WorkspaceFolder: "/workspaces/long-error-dev", + ConfigPath: "/workspaces/long-error-dev/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusError, + Dirty: false, + Error: "Failed to build devcontainer: dockerfile parse error at line 25: unknown instruction 'INSTALL', did you mean 'RUN apt-get install'? This is a very long error message that should be truncated when detail flag is not used", + Container: nil, + Agent: nil, + }, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var allAgents []codersdk.WorkspaceAgent + mainAgent := codersdk.WorkspaceAgent{ + ID: mainAgentID, + Name: "main", + OperatingSystem: "linux", + Architecture: "amd64", + Status: codersdk.WorkspaceAgentConnected, + Health: codersdk.WorkspaceAgentHealth{Healthy: true}, + Version: "v2.15.0", + } + allAgents = append(allAgents, mainAgent) + + for _, dc := range tc.devcontainers { + if dc.Agent != nil { + devcontainerAgent := codersdk.WorkspaceAgent{ + ID: dc.Agent.ID, + ParentID: uuid.NullUUID{UUID: mainAgentID, Valid: true}, + Name: dc.Agent.Name, + OperatingSystem: "linux", + Architecture: "amd64", + Status: codersdk.WorkspaceAgentConnected, + Health: codersdk.WorkspaceAgentHealth{Healthy: true}, + Version: "v2.15.0", + } + allAgents = append(allAgents, devcontainerAgent) + } + } + + resources := []codersdk.WorkspaceResource{ + { + Type: "compute", + Name: "main", + Agents: allAgents, + }, + } + options := cliui.WorkspaceResourcesOptions{ + WorkspaceName: "test-workspace", + ServerVersion: "v2.15.0", + ShowDetails: tc.showDetails, + Devcontainers: map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse{ + agentID: { + Devcontainers: tc.devcontainers, + }, + }, + ListeningPorts: tc.listeningPorts, + } + + var buf bytes.Buffer + err := cliui.WorkspaceResources(&buf, resources, options) + require.NoError(t, err) + + replacements := map[string]string{} + clitest.TestGoldenFile(t, "TestShowDevcontainers_Golden/"+tc.name, buf.Bytes(), replacements) + }) + } +} diff --git a/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden b/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden new file mode 100644 index 0000000000000..2dc7ad1ec957d --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden @@ -0,0 +1,9 @@ +┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ failed-dev ✘ error │ +│ × Failed to pull image mcr.microsoft.com/devcontainers/go:latest: timeout after... │ +└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden b/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden new file mode 100644 index 0000000000000..3405220846b88 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden @@ -0,0 +1,9 @@ +┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ long-error-dev ✘ error │ +│ × Failed to build devcontainer: dockerfile parse error at line 25: unknown inst... │ +└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/long_error_message_with_detail.golden b/cli/testdata/TestShowDevcontainers_Golden/long_error_message_with_detail.golden new file mode 100644 index 0000000000000..9310f7f19a350 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/long_error_message_with_detail.golden @@ -0,0 +1,9 @@ +┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ long-error-dev ✘ error │ +│ × Failed to build devcontainer: dockerfile parse error at line 25: unknown instruction 'INSTALL', did you mean 'RUN apt-get install'? This is a very long error message that should be truncated when detail flag is not used │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/mixed_devcontainer_states.golden b/cli/testdata/TestShowDevcontainers_Golden/mixed_devcontainer_states.golden new file mode 100644 index 0000000000000..dfbd677cc3dbe --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/mixed_devcontainer_states.golden @@ -0,0 +1,13 @@ +┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ ├─ frontend (linux, amd64) [vibrant_tesla] ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.frontend │ +│ ├─ Open Ports │ +│ └─ 5173/tcp [vite] │ +│ ├─ backend [peaceful_curie] ⏹ stopped │ +│ └─ error-container ✘ error │ +│ × Container build failed: dockerfile syntax error on line 15 │ +└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent.golden b/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent.golden new file mode 100644 index 0000000000000..ab5d2a2085227 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent.golden @@ -0,0 +1,11 @@ +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ web-dev (linux, amd64) [quirky_lovelace] ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.web-dev │ +│ └─ Open Ports │ +│ ├─ 3000/tcp [node] │ +│ └─ 8080/tcp [webpack-dev-server] │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent_and_error.golden b/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent_and_error.golden new file mode 100644 index 0000000000000..6b73f7175bac8 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_with_agent_and_error.golden @@ -0,0 +1,11 @@ +┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ problematic-dev (linux, amd64) [cranky_mendel] ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.problematic-dev │ +│ × Warning: Container started but healthcheck failed │ +│ └─ Open Ports │ +│ └─ 8000/tcp [python] │ +└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_without_agent.golden b/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_without_agent.golden new file mode 100644 index 0000000000000..70c3874acc774 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/running_devcontainer_without_agent.golden @@ -0,0 +1,8 @@ +┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├──────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ web-server [amazing_turing] ▶ running │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/starting_devcontainer.golden b/cli/testdata/TestShowDevcontainers_Golden/starting_devcontainer.golden new file mode 100644 index 0000000000000..472201ecc7818 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/starting_devcontainer.golden @@ -0,0 +1,8 @@ +┌───────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ database-dev [nostalgic_hawking] ⧗ starting │ +└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/stopped_devcontainer.golden b/cli/testdata/TestShowDevcontainers_Golden/stopped_devcontainer.golden new file mode 100644 index 0000000000000..41313b235acc7 --- /dev/null +++ b/cli/testdata/TestShowDevcontainers_Golden/stopped_devcontainer.golden @@ -0,0 +1,8 @@ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ RESOURCE STATUS HEALTH VERSION ACCESS │ +├──────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ compute.main │ +│ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ +│ └─ Devcontainers │ +│ └─ api-dev [clever_darwin] ⏹ stopped │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/coder_show_--help.golden b/cli/testdata/coder_show_--help.golden index fc048aa067ea6..ebf9d5802c823 100644 --- a/cli/testdata/coder_show_--help.golden +++ b/cli/testdata/coder_show_--help.golden @@ -1,9 +1,13 @@ coder v0.0.0-devel USAGE: - coder show + coder show [flags] Display details of a workspace's resources and agents +OPTIONS: + --details bool + Show full error messages and additional details. + ——— Run `coder --help` for a list of global options. From eb04c1a9c36c9108a0084eecff5a0a8f2c16713c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 14:39:58 +0000 Subject: [PATCH 2/8] remove silly --- cli/cliui/resources.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index cf0c7474fea18..5d2948303c05a 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -211,12 +211,9 @@ func renderDevcontainerRow(resources []codersdk.WorkspaceResource, devcontainer break } } - - displayName = devcontainer.Agent.Name if subAgent != nil { + displayName = subAgent.Name displayName += fmt.Sprintf(" (%s, %s)", subAgent.OperatingSystem, subAgent.Architecture) - } else { - displayName += " (linux, amd64)" } } From 5519faa5ca56065d290f3c1903504033b1e9d685 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 14:50:41 +0000 Subject: [PATCH 3/8] make gen --- docs/reference/cli/show.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli/show.md b/docs/reference/cli/show.md index 87c527ed939f9..6ce54d4111a30 100644 --- a/docs/reference/cli/show.md +++ b/docs/reference/cli/show.md @@ -6,5 +6,15 @@ Display details of a workspace's resources and agents ## Usage ```console -coder show +coder show [flags] ``` + +## Options + +### --details + +| | | +|------|-------------------| +| Type | bool | + +Show full error messages and additional details. From 1e5234c1293414176ce173609433b8f8a0fc0370 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 15:06:58 +0000 Subject: [PATCH 4/8] do not fetch containers for devcontainers --- cli/show.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/show.go b/cli/show.go index b5a61c878976d..882b129d03f40 100644 --- a/cli/show.go +++ b/cli/show.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" @@ -78,13 +79,17 @@ func fetchRuntimeResources(inv *serpent.Invocation, client *codersdk.Client, res ports[agent.ID] = lp mu.Unlock() }() + + if agent.ParentID.Valid { + continue + } wg.Add(1) go func() { defer wg.Done() dc, err := client.WorkspaceAgentListContainers(inv.Context(), agent.ID, map[string]string{ // Labels set by VSCode Remote Containers and @devcontainers/cli. - "devcontainer.config_file": "", - "devcontainer.local_folder": "", + agentcontainers.DevcontainerConfigFileLabel: "", + agentcontainers.DevcontainerLocalFolderLabel: "", }) if err != nil { cliui.Warnf(inv.Stderr, "Failed to get devcontainers for agent %s: %v", agent.Name, err) From f5fa73c3073fd734047a4726c6ce2973d52f1696 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 15:46:05 +0000 Subject: [PATCH 5/8] slice.Find --- cli/cliui/resources.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 5d2948303c05a..deb17faeb9926 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -201,13 +201,10 @@ func renderDevcontainerRow(resources []codersdk.WorkspaceResource, devcontainer displayName := devcontainer.Name if devcontainer.Agent != nil && devcontainer.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning { for _, resource := range resources { - for _, agent := range resource.Agents { - if agent.ID == devcontainer.Agent.ID { - subAgent = &agent - break - } - } - if subAgent != nil { + if agent, found := slice.Find(resource.Agents, func(agent codersdk.WorkspaceAgent) bool { + return agent.ID == devcontainer.Agent.ID + }); found { + subAgent = &agent break } } From 6bfa2232fc95ec4a728fcc5ae211edcb68270ffb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 15:47:04 +0000 Subject: [PATCH 6/8] =?UTF-8?q?use=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/cliui/resources.go | 2 +- .../TestShowDevcontainers_Golden/error_devcontainer.golden | 2 +- .../TestShowDevcontainers_Golden/long_error_message.golden | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index deb17faeb9926..36ce4194d72c8 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -252,7 +252,7 @@ func renderDevcontainerRow(resources []codersdk.WorkspaceResource, devcontainer if errorMessage := devcontainer.Error; errorMessage != "" { // Cap error message length for display. if !wro.ShowDetails && len(errorMessage) > 80 { - errorMessage = errorMessage[:77] + "..." + errorMessage = errorMessage[:79] + "…" } errorRow := table.Row{ " × " + pretty.Sprint(DefaultStyles.Error, errorMessage), diff --git a/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden b/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden index 2dc7ad1ec957d..03a19f16df4e1 100644 --- a/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden +++ b/cli/testdata/TestShowDevcontainers_Golden/error_devcontainer.golden @@ -5,5 +5,5 @@ │ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ │ └─ Devcontainers │ │ └─ failed-dev ✘ error │ -│ × Failed to pull image mcr.microsoft.com/devcontainers/go:latest: timeout after... │ +│ × Failed to pull image mcr.microsoft.com/devcontainers/go:latest: timeout after 5… │ └─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden b/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden index 3405220846b88..1e80d338a74a8 100644 --- a/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden +++ b/cli/testdata/TestShowDevcontainers_Golden/long_error_message.golden @@ -5,5 +5,5 @@ │ └─ main (linux, amd64) ⦿ connected ✔ healthy v2.15.0 coder ssh test-workspace.main │ │ └─ Devcontainers │ │ └─ long-error-dev ✘ error │ -│ × Failed to build devcontainer: dockerfile parse error at line 25: unknown inst... │ +│ × Failed to build devcontainer: dockerfile parse error at line 25: unknown instru… │ └─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ From 4d01a607442342d535ceb584546d0ef66b37ca46 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 15:47:30 +0000 Subject: [PATCH 7/8] default false --- cli/show.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/show.go b/cli/show.go index 882b129d03f40..284e8581f5dda 100644 --- a/cli/show.go +++ b/cli/show.go @@ -24,6 +24,7 @@ func (r *RootCmd) show() *serpent.Command { { Flag: "details", Description: "Show full error messages and additional details.", + Default: "false", Value: serpent.BoolOf(&details), }, }, From 2eab3907edf569994a8eb6ae55f53d31521e8298 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 8 Jul 2025 15:48:33 +0000 Subject: [PATCH 8/8] make gen --- cli/testdata/coder_show_--help.golden | 2 +- docs/reference/cli/show.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/testdata/coder_show_--help.golden b/cli/testdata/coder_show_--help.golden index ebf9d5802c823..76555221e4602 100644 --- a/cli/testdata/coder_show_--help.golden +++ b/cli/testdata/coder_show_--help.golden @@ -6,7 +6,7 @@ USAGE: Display details of a workspace's resources and agents OPTIONS: - --details bool + --details bool (default: false) Show full error messages and additional details. ——— diff --git a/docs/reference/cli/show.md b/docs/reference/cli/show.md index 6ce54d4111a30..c6fb9a2c81f64 100644 --- a/docs/reference/cli/show.md +++ b/docs/reference/cli/show.md @@ -13,8 +13,9 @@ coder show [flags] ### --details -| | | -|------|-------------------| -| Type | bool | +| | | +|---------|--------------------| +| Type | bool | +| Default | false | Show full error messages and additional details.