From 4867cbe53d858e6afa2641accde4c69402313466 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 11 Feb 2025 09:20:55 +0000 Subject: [PATCH] feat(cli): display devcontainers in show command (#16515) Displays running devcontainers into the `coder show` CLI command. --- cli/cliui/resources.go | 78 +++++++++++++++++++++++++++++----- cli/show.go | 24 +++++++++-- coderd/util/maps/maps.go | 7 ++- coderd/util/maps/maps_test.go | 23 +++++++++- coderd/workspaceagents_test.go | 8 +++- 5 files changed, 121 insertions(+), 19 deletions(-) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 8921033ddc..25277645ce 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -28,6 +28,7 @@ type WorkspaceResourcesOptions struct { Title string ServerVersion string ListeningPorts map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse + Devcontainers map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse } // WorkspaceResources displays the connection status and tree-view of provided resources. @@ -95,15 +96,11 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource // Display all agents associated with the resource. for index, agent := range resource.Agents { tableWriter.AppendRow(renderAgentRow(agent, index, totalAgents, options)) - if options.ListeningPorts != nil { - if lp, ok := options.ListeningPorts[agent.ID]; ok && len(lp.Ports) > 0 { - tableWriter.AppendRow(table.Row{ - fmt.Sprintf(" %s─ %s", renderPipe(index, totalAgents), "Open Ports"), - }) - for _, port := range lp.Ports { - tableWriter.AppendRow(renderPortRow(port, index, totalAgents)) - } - } + for _, row := range renderListeningPorts(options, agent.ID, index, totalAgents) { + tableWriter.AppendRow(row) + } + for _, row := range renderDevcontainers(options, agent.ID, index, totalAgents) { + tableWriter.AppendRow(row) } } tableWriter.AppendSeparator() @@ -137,10 +134,28 @@ func renderAgentRow(agent codersdk.WorkspaceAgent, index, totalAgents int, optio return row } -func renderPortRow(port codersdk.WorkspaceAgentListeningPort, index, totalPorts int) table.Row { +func renderListeningPorts(wro WorkspaceResourcesOptions, agentID uuid.UUID, idx, total int) []table.Row { + var rows []table.Row + if wro.ListeningPorts == nil { + return []table.Row{} + } + lp, ok := wro.ListeningPorts[agentID] + if !ok || len(lp.Ports) == 0 { + return []table.Row{} + } + rows = append(rows, table.Row{ + fmt.Sprintf(" %s─ Open Ports", renderPipe(idx, total)), + }) + for idx, port := range lp.Ports { + rows = append(rows, renderPortRow(port, idx, len(lp.Ports))) + } + return rows +} + +func renderPortRow(port codersdk.WorkspaceAgentListeningPort, idx, total int) table.Row { var sb strings.Builder _, _ = sb.WriteString(" ") - _, _ = sb.WriteString(renderPipe(index, totalPorts)) + _, _ = sb.WriteString(renderPipe(idx, total)) _, _ = sb.WriteString("─ ") _, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Code, "%5d/%s", port.Port, port.Network)) if port.ProcessName != "" { @@ -149,6 +164,47 @@ func renderPortRow(port codersdk.WorkspaceAgentListeningPort, index, totalPorts return table.Row{sb.String()} } +func renderDevcontainers(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 { + 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))) + } + return rows +} + +func renderDevcontainerRow(container codersdk.WorkspaceAgentDevcontainer, 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)) + } + 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 { switch agent.Status { case codersdk.WorkspaceAgentConnecting: diff --git a/cli/show.go b/cli/show.go index 7da747d6ff..f2d3df3ecc 100644 --- a/cli/show.go +++ b/cli/show.go @@ -38,15 +38,18 @@ func (r *RootCmd) show() *serpent.Command { } if workspace.LatestBuild.Status == codersdk.WorkspaceStatusRunning { // Get listening ports for each agent. - options.ListeningPorts = fetchListeningPorts(inv, client, workspace.LatestBuild.Resources...) + ports, devcontainers := fetchRuntimeResources(inv, client, workspace.LatestBuild.Resources...) + options.ListeningPorts = ports + options.Devcontainers = devcontainers } return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, options) }, } } -func fetchListeningPorts(inv *serpent.Invocation, client *codersdk.Client, resources ...codersdk.WorkspaceResource) map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse { +func fetchRuntimeResources(inv *serpent.Invocation, client *codersdk.Client, resources ...codersdk.WorkspaceResource) (map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse, map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse) { ports := make(map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse) + devcontainers := make(map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse) var wg sync.WaitGroup var mu sync.Mutex for _, res := range resources { @@ -65,8 +68,23 @@ func fetchListeningPorts(inv *serpent.Invocation, client *codersdk.Client, resou ports[agent.ID] = lp mu.Unlock() }() + 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": "", + }) + if err != nil { + cliui.Warnf(inv.Stderr, "Failed to get devcontainers for agent %s: %v", agent.Name, err) + } + mu.Lock() + devcontainers[agent.ID] = dc + mu.Unlock() + }() } } wg.Wait() - return ports + return ports, devcontainers } diff --git a/coderd/util/maps/maps.go b/coderd/util/maps/maps.go index 6d3d31717d..8aaa6669cb 100644 --- a/coderd/util/maps/maps.go +++ b/coderd/util/maps/maps.go @@ -8,9 +8,14 @@ import ( // Subset returns true if all the keys of a are present // in b and have the same values. +// If the corresponding value of a[k] is the zero value in +// b, Subset will skip comparing that value. +// This allows checking for the presence of map keys. func Subset[T, U comparable](a, b map[T]U) bool { + var uz U for ka, va := range a { - if vb, ok := b[ka]; !ok || va != vb { + ignoreZeroValue := va == uz + if vb, ok := b[ka]; !ok || (!ignoreZeroValue && va != vb) { return false } } diff --git a/coderd/util/maps/maps_test.go b/coderd/util/maps/maps_test.go index 1858d6467e..543c100c21 100644 --- a/coderd/util/maps/maps_test.go +++ b/coderd/util/maps/maps_test.go @@ -11,8 +11,9 @@ func TestSubset(t *testing.T) { t.Parallel() for idx, tc := range []struct { - a map[string]string - b map[string]string + a map[string]string + b map[string]string + // expected value from Subset expected bool }{ { @@ -50,6 +51,24 @@ func TestSubset(t *testing.T) { b: map[string]string{"a": "1", "b": "3"}, expected: false, }, + // Zero value + { + a: map[string]string{"a": "1", "b": ""}, + b: map[string]string{"a": "1", "b": "3"}, + expected: true, + }, + // Zero value, but the other way round + { + a: map[string]string{"a": "1", "b": "3"}, + b: map[string]string{"a": "1", "b": ""}, + expected: false, + }, + // Both zero values + { + a: map[string]string{"a": "1", "b": ""}, + b: map[string]string{"a": "1", "b": ""}, + expected: true, + }, } { tc := tc t.Run("#"+strconv.Itoa(idx), func(t *testing.T) { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f7a3513d4f..7a051ef233 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1076,7 +1076,8 @@ func TestWorkspaceAgentContainers(t *testing.T) { pool, err := dockertest.NewPool("") require.NoError(t, err, "Could not connect to docker") testLabels := map[string]string{ - "com.coder.test": uuid.New().String(), + "com.coder.test": uuid.New().String(), + "com.coder.empty": "", } ct, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "busybox", @@ -1097,7 +1098,10 @@ func TestWorkspaceAgentContainers(t *testing.T) { Repository: "busybox", Tag: "latest", Cmd: []string{"sleep", "infinity"}, - Labels: map[string]string{"com.coder.test": "ignoreme"}, + Labels: map[string]string{ + "com.coder.test": "ignoreme", + "com.coder.empty": "", + }, }, func(config *docker.HostConfig) { config.AutoRemove = true config.RestartPolicy = docker.RestartPolicy{Name: "no"}