Skip to content
Prev Previous commit
Next Next commit
feat(support): fetch manifest, logs etc. from agent
  • Loading branch information
johnstcn committed Mar 14, 2024
commit 1092eb25e78fc8ed0675aa087deed20fd6794197
5 changes: 5 additions & 0 deletions cli/support.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
"network/netcheck_remote.json": src.Network.NetcheckRemote,
"workspace/workspace.json": src.Workspace.Workspace,
"agent/agent.json": src.Agent.Agent,
"agent/listening_ports.json": src.Agent.ListeningPorts,
"agent/manifest.json": src.Agent.Manifest,
"agent/peer_diagnostics.json": src.Agent.PeerDiagnostics,
"agent/ping_result.json": src.Agent.PingResult,
"workspace/template.json": src.Workspace.Template,
"workspace/template_version.json": src.Workspace.TemplateVersion,
"workspace/parameters.json": src.Workspace.Parameters,
Expand All @@ -170,6 +174,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
"network/coordinator_debug.html": src.Network.CoordinatorDebug,
"network/tailnet_debug.html": src.Network.TailnetDebug,
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs),
"agent/logs.txt": string(src.Agent.Logs),
"agent/startup_logs.txt": humanizeAgentLogs(src.Agent.StartupLogs),
"workspace/template_file.zip": string(templateVersionBytes),
"logs.txt": strings.Join(src.Logs, "\n"),
Expand Down
32 changes: 29 additions & 3 deletions cli/support_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ import (
"testing"
"time"

"tailscale.com/ipn/ipnstate"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)

Expand All @@ -37,7 +42,9 @@ func TestSupportBundle(t *testing.T) {
}).WithAgent().Do()
ws, err := client.Workspace(ctx, r.Workspace.ID)
require.NoError(t, err)
agt := ws.LatestBuild.Resources[0].Agents[0]
agt := agenttest.New(t, client.URL, r.AgentToken)
defer agt.Close()
coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()

// Insert a provisioner job log
_, err = db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{
Expand All @@ -51,7 +58,7 @@ func TestSupportBundle(t *testing.T) {
require.NoError(t, err)
// Insert an agent log
_, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{
AgentID: agt.ID,
AgentID: ws.LatestBuild.Resources[0].Agents[0].ID,
CreatedAt: dbtime.Now(),
Output: []string{"started up"},
Level: []database.LogLevel{database.LogLevelInfo},
Expand Down Expand Up @@ -156,6 +163,25 @@ func assertBundleContents(t *testing.T, path string) {
var v codersdk.WorkspaceAgent
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "agent should not be empty")
case "agent/listening_ports.json":
var v codersdk.WorkspaceAgentListeningPortsResponse
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "agent listening ports should not be empty")
case "agent/logs.txt":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "logs should not be empty")
case "agent/manifest.json":
var v agentsdk.Manifest
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "agent manifest should not be empty")
case "agent/peer_diagnostics.json":
var v *tailnet.PeerDiagnostics
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "peer diagnostics should not be empty")
case "agent/ping_result.json":
var v *ipnstate.PingResult
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "ping result should not be empty")
case "agent/startup_logs.txt":
bs := readBytesFromZip(t, f)
require.Contains(t, string(bs), "started up")
Expand All @@ -178,7 +204,7 @@ func assertBundleContents(t *testing.T, path string) {
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "logs should not be empty")
default:
require.Failf(t, "unexpected file in bundle: %q", f.Name)
require.Failf(t, "unexpected file in bundle", f.Name)
}
}
}
Expand Down
119 changes: 117 additions & 2 deletions support/support.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
package support

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"strings"

"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"tailscale.com/ipn/ipnstate"

"github.com/google/uuid"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/tailnet"
)

// Bundle is a set of information discovered about a deployment.
Expand Down Expand Up @@ -53,8 +59,13 @@ type Workspace struct {
}

type Agent struct {
Agent codersdk.WorkspaceAgent `json:"agent"`
StartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"`
Agent codersdk.WorkspaceAgent `json:"agent"`
ListeningPorts *codersdk.WorkspaceAgentListeningPortsResponse `json:"listening_ports"`
Logs []byte `json:"logs"`
Manifest *agentsdk.Manifest `json:"manifest"`
PeerDiagnostics *tailnet.PeerDiagnostics `json:"peer_diagnostics"`
PingResult *ipnstate.PingResult `json:"ping_result"`
StartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"`
}

// Deps is a set of dependencies for discovering information
Expand Down Expand Up @@ -303,6 +314,72 @@ func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, ag
return nil
})

conn, err := client.DialWorkspaceAgent(ctx, agentID, &codersdk.DialWorkspaceAgentOptions{
Logger: log.Named("dial-agent"),
BlockEndpoints: false,
})
if err != nil {
log.Error(ctx, "dial agent", slog.Error(err))
} else {
defer conn.Close()
if !conn.AwaitReachable(ctx) {
log.Error(ctx, "timed out waiting for agent")
} else {
eg.Go(func() error {
_, _, pingRes, err := conn.Ping(ctx)
if err != nil {
return xerrors.Errorf("ping agent: %w", err)
}
a.PingResult = pingRes
return nil
})

eg.Go(func() error {
pds := conn.GetPeerDiagnostics()
a.PeerDiagnostics = &pds
return nil
})

eg.Go(func() error {
tokenBytes, err := runCmd(ctx, client, agentID, `echo $CODER_AGENT_TOKEN`)
if err != nil {
return xerrors.Errorf("failed to fetch agent token: %w", err)
}
token := string(bytes.TrimSpace(tokenBytes))
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(token)
manifestRes, err := agentClient.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/manifest", nil)
if err != nil {
return xerrors.Errorf("fetch manifest: %w", err)
}
defer manifestRes.Body.Close()
if err := json.NewDecoder(manifestRes.Body).Decode(&a.Manifest); err != nil {
return xerrors.Errorf("decode agent manifest: %w", err)
}

return nil
})

eg.Go(func() error {
logBytes, err := runCmd(ctx, client, agentID, `tail -n 1000 /tmp/coder-agent.log`)
if err != nil {
return xerrors.Errorf("tail /tmp/coder-agent.log: %w", err)
}
a.Logs = logBytes
return nil
})

eg.Go(func() error {
lps, err := conn.ListeningPorts(ctx)
if err != nil {
return xerrors.Errorf("get listening ports: %w", err)
}
a.ListeningPorts = &lps
return nil
})
}
}

if err := eg.Wait(); err != nil {
log.Error(ctx, "fetch agent information", slog.Error(err))
}
Expand Down Expand Up @@ -380,3 +457,41 @@ func sanitizeEnv(kvs map[string]string) {
}
}
}

// TODO: use rpty instead? which is less liable to fail?
func runCmd(ctx context.Context, client *codersdk.Client, agentID uuid.UUID, cmd string) ([]byte, error) {
var err error
var closers []func() error
defer func() {
if err != nil {
for _, c := range closers {
if err2 := c(); err2 != nil {
err = errors.Join(err, err2)
}
}
}
}()
agentConn, err := client.DialWorkspaceAgent(ctx, agentID, &codersdk.DialWorkspaceAgentOptions{})
if err != nil {
return nil, xerrors.Errorf("dial workspace agent: %w", err)
}
closers = append(closers, agentConn.Close)

sshClient, err := agentConn.SSHClient(ctx)
if err != nil {
return nil, xerrors.Errorf("get ssh client: %w", err)
}
closers = append(closers, sshClient.Close)

sshSession, err := sshClient.NewSession()
if err != nil {
return nil, xerrors.Errorf("new ssh session: %w", err)
}
closers = append(closers, sshSession.Close)

cmdBytes, err := sshSession.CombinedOutput(cmd)
if err != nil {
return nil, xerrors.Errorf("shell: %w", err)
}
return cmdBytes, err
}
9 changes: 9 additions & 0 deletions support/support_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
Expand Down Expand Up @@ -64,6 +65,11 @@ func TestRun(t *testing.T) {
require.NotEmpty(t, bun.Workspace.TemplateFileBase64)
require.NotNil(t, bun.Workspace.Parameters)
require.NotNil(t, bun.Agent.Agent)
require.NotNil(t, bun.Agent.ListeningPorts)
require.NotNil(t, bun.Agent.Logs)
require.NotNil(t, bun.Agent.Manifest)
require.NotNil(t, bun.Agent.PeerDiagnostics)
require.NotNil(t, bun.Agent.PingResult)
require.NotEmpty(t, bun.Agent.StartupLogs)
require.NotEmpty(t, bun.Logs)
})
Expand Down Expand Up @@ -200,5 +206,8 @@ func setupWorkspaceAndAgent(ctx context.Context, t *testing.T, client *codersdk.
})
require.NoError(t, err)

_ = agenttest.New(t, client.URL, wbr.AgentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, wbr.Workspace.ID).Wait()

return ws, agt
}