feat(support): fetch agent network info over tailnet (#12577)

Adds agent-related information to support bundle command.
This commit is contained in:
Cian Johnston
2024-03-15 11:01:39 +00:00
committed by GitHub
parent 653ddccd8e
commit 2abc1cd2b7
4 changed files with 265 additions and 90 deletions
+139 -38
View File
@@ -1,14 +1,17 @@
package support
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"tailscale.com/ipn/ipnstate"
"github.com/google/uuid"
@@ -16,6 +19,8 @@ import (
"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.
@@ -25,6 +30,7 @@ type Bundle struct {
Deployment Deployment `json:"deployment"`
Network Network `json:"network"`
Workspace Workspace `json:"workspace"`
Agent Agent `json:"agent"`
Logs []string `json:"logs"`
}
@@ -38,8 +44,7 @@ type Deployment struct {
type Network struct {
CoordinatorDebug string `json:"coordinator_debug"`
TailnetDebug string `json:"tailnet_debug"`
NetcheckLocal *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck_local"`
NetcheckRemote *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck_remote"`
Netcheck *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck"`
}
type Workspace struct {
@@ -49,8 +54,17 @@ type Workspace struct {
TemplateVersion codersdk.TemplateVersion `json:"template_version"`
TemplateFileBase64 string `json:"template_file_base64"`
BuildLogs []codersdk.ProvisionerJobLog `json:"build_logs"`
Agent codersdk.WorkspaceAgent `json:"agent"`
AgentStartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"`
}
type Agent struct {
Agent *codersdk.WorkspaceAgent `json:"agent"`
ListeningPorts *codersdk.WorkspaceAgentListeningPortsResponse `json:"listening_ports"`
Logs []byte `json:"logs"`
MagicsockHTML []byte `json:"magicsock_html"`
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
@@ -159,7 +173,7 @@ func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger,
if err != nil {
return xerrors.Errorf("fetch agent conn info: %w", err)
}
n.NetcheckLocal = &connInfo
n.Netcheck = &connInfo
return nil
})
@@ -170,7 +184,7 @@ func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger,
return n
}
func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, workspaceID, agentID uuid.UUID) Workspace {
func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, workspaceID uuid.UUID) Workspace {
var (
w Workspace
eg errgroup.Group
@@ -181,10 +195,6 @@ func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger
return w
}
if agentID == uuid.Nil {
log.Error(ctx, "no agent id specified")
}
// dependency, cannot fetch concurrently
ws, err := client.Workspace(ctx, workspaceID)
if err != nil {
@@ -198,15 +208,6 @@ func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger
}
w.Workspace = ws
eg.Go(func() error {
agt, err := client.WorkspaceAgent(ctx, agentID)
if err != nil {
return xerrors.Errorf("fetch workspace agent: %w", err)
}
w.Agent = agt
return nil
})
eg.Go(func() error {
buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0)
if err != nil {
@@ -221,24 +222,6 @@ func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger
return nil
})
eg.Go(func() error {
if len(w.Workspace.LatestBuild.Resources) == 0 {
log.Warn(ctx, "workspace build has no resources")
return nil
}
agentLogCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agentID, 0, false)
if err != nil {
return xerrors.Errorf("fetch agent startup logs: %w", err)
}
defer closer.Close()
var logs []codersdk.WorkspaceAgentLog
for logChunk := range agentLogCh {
logs = append(w.AgentStartupLogs, logChunk...)
}
w.AgentStartupLogs = logs
return nil
})
eg.Go(func() error {
if w.Workspace.TemplateActiveVersionID == uuid.Nil {
return xerrors.Errorf("workspace has nil template active version id")
@@ -296,6 +279,119 @@ func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger
return w
}
func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID) Agent {
var (
a Agent
eg errgroup.Group
)
if agentID == uuid.Nil {
log.Error(ctx, "no agent id specified")
return a
}
eg.Go(func() error {
agt, err := client.WorkspaceAgent(ctx, agentID)
if err != nil {
return xerrors.Errorf("fetch workspace agent: %w", err)
}
a.Agent = &agt
return nil
})
eg.Go(func() error {
agentLogCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agentID, 0, false)
if err != nil {
return xerrors.Errorf("fetch agent startup logs: %w", err)
}
defer closer.Close()
var logs []codersdk.WorkspaceAgentLog
for logChunk := range agentLogCh {
logs = append(logs, logChunk...)
}
a.StartupLogs = logs
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 func() {
if err := conn.Close(); err != nil {
log.Error(ctx, "failed to close agent connection", slog.Error(err))
}
<-conn.Closed()
}()
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 {
msBytes, err := conn.DebugMagicsock(ctx)
if err != nil {
return xerrors.Errorf("get agent magicsock page: %w", err)
}
a.MagicsockHTML = msBytes
return nil
})
eg.Go(func() error {
manifestRes, err := conn.DebugManifest(ctx)
if err != nil {
return xerrors.Errorf("fetch manifest: %w", err)
}
if err := json.NewDecoder(bytes.NewReader(manifestRes)).Decode(&a.Manifest); err != nil {
return xerrors.Errorf("decode agent manifest: %w", err)
}
return nil
})
eg.Go(func() error {
logBytes, err := conn.DebugLogs(ctx)
if err != nil {
return xerrors.Errorf("fetch coder agent logs: %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))
}
return a
}
// Run generates a support bundle with the given dependencies.
func Run(ctx context.Context, d *Deps) (*Bundle, error) {
var b Bundle
@@ -337,7 +433,7 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) {
return nil
})
eg.Go(func() error {
wi := WorkspaceInfo(ctx, d.Client, d.Log, d.WorkspaceID, d.AgentID)
wi := WorkspaceInfo(ctx, d.Client, d.Log, d.WorkspaceID)
b.Workspace = wi
return nil
})
@@ -346,6 +442,11 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) {
b.Network = ni
return nil
})
eg.Go(func() error {
ai := AgentInfo(ctx, d.Client, d.Log, d.AgentID)
b.Agent = ai
return nil
})
_ = eg.Wait()
+65 -34
View File
@@ -5,15 +5,20 @@ import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent"
"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"
@@ -24,6 +29,10 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestRun(t *testing.T) {
t.Parallel()
@@ -31,7 +40,7 @@ func TestRun(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentValues(t)
cfg.Experiments = []string{"foo"}
ctx := testutil.Context(t, testutil.WaitShort)
ctx := testutil.Context(t, testutil.WaitLong)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: cfg,
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
@@ -46,33 +55,38 @@ func TestRun(t *testing.T) {
AgentID: agt.ID,
})
require.NoError(t, err)
require.NotEmpty(t, bun)
require.NotEmpty(t, bun.Deployment.BuildInfo)
require.NotEmpty(t, bun.Deployment.Config)
require.NotEmpty(t, bun.Deployment.Config.Options)
assertNotNilNotEmpty(t, bun, "bundle should be present")
assertNotNilNotEmpty(t, bun.Deployment.BuildInfo, "deployment build info should be present")
assertNotNilNotEmpty(t, bun.Deployment.Config, "deployment config should be present")
assertNotNilNotEmpty(t, bun.Deployment.Config.Options, "deployment config should be present")
assertSanitizedDeploymentConfig(t, bun.Deployment.Config)
require.NotEmpty(t, bun.Deployment.HealthReport)
require.NotEmpty(t, bun.Deployment.Experiments)
require.NotEmpty(t, bun.Network.CoordinatorDebug)
require.NotEmpty(t, bun.Network.TailnetDebug)
require.NotNil(t, bun.Network.NetcheckLocal)
require.NotNil(t, bun.Workspace.Workspace)
assertNotNilNotEmpty(t, bun.Deployment.HealthReport, "deployment health report should be present")
assertNotNilNotEmpty(t, bun.Deployment.Experiments, "deployment experiments should be present")
assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present")
assertNotNilNotEmpty(t, bun.Network.TailnetDebug, "network tailnet debug should be present")
assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present")
assertNotNilNotEmpty(t, bun.Workspace.Workspace, "workspace should be present")
assertSanitizedWorkspace(t, bun.Workspace.Workspace)
require.NotEmpty(t, bun.Workspace.BuildLogs)
require.NotNil(t, bun.Workspace.Agent)
require.NotEmpty(t, bun.Workspace.AgentStartupLogs)
require.NotEmpty(t, bun.Workspace.Template)
require.NotEmpty(t, bun.Workspace.TemplateVersion)
require.NotEmpty(t, bun.Workspace.TemplateFileBase64)
require.NotNil(t, bun.Workspace.Parameters)
require.NotEmpty(t, bun.Logs)
assertNotNilNotEmpty(t, bun.Workspace.BuildLogs, "workspace build logs should be present")
assertNotNilNotEmpty(t, bun.Workspace.Template, "workspace template should be present")
assertNotNilNotEmpty(t, bun.Workspace.TemplateVersion, "workspace template version should be present")
assertNotNilNotEmpty(t, bun.Workspace.TemplateFileBase64, "workspace template file should be present")
require.NotNil(t, bun.Workspace.Parameters, "workspace parameters should be present")
assertNotNilNotEmpty(t, bun.Agent.Agent, "agent should be present")
assertNotNilNotEmpty(t, bun.Agent.ListeningPorts, "agent listening ports should be present")
assertNotNilNotEmpty(t, bun.Agent.Logs, "agent logs should be present")
assertNotNilNotEmpty(t, bun.Agent.MagicsockHTML, "agent magicsock should be present")
assertNotNilNotEmpty(t, bun.Agent.PeerDiagnostics, "agent peer diagnostics should be present")
assertNotNilNotEmpty(t, bun.Agent.PingResult, "agent ping result should be present")
assertNotNilNotEmpty(t, bun.Agent.StartupLogs, "agent startup logs should be present")
assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present")
})
t.Run("OK_NoAgent", func(t *testing.T) {
t.Run("OK_NoWorkspace", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentValues(t)
cfg.Experiments = []string{"foo"}
ctx := testutil.Context(t, testutil.WaitShort)
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: cfg,
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
@@ -83,23 +97,24 @@ func TestRun(t *testing.T) {
Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug),
})
require.NoError(t, err)
require.NotEmpty(t, bun)
require.NotEmpty(t, bun.Deployment.BuildInfo)
require.NotEmpty(t, bun.Deployment.Config)
require.NotEmpty(t, bun.Deployment.Config.Options)
assertNotNilNotEmpty(t, bun, "bundle should be present")
assertNotNilNotEmpty(t, bun.Deployment.BuildInfo, "deployment build info should be present")
assertNotNilNotEmpty(t, bun.Deployment.Config, "deployment config should be present")
assertNotNilNotEmpty(t, bun.Deployment.Config.Options, "deployment config should be present")
assertSanitizedDeploymentConfig(t, bun.Deployment.Config)
require.NotEmpty(t, bun.Deployment.HealthReport)
require.NotEmpty(t, bun.Deployment.Experiments)
require.NotEmpty(t, bun.Network.CoordinatorDebug)
require.NotEmpty(t, bun.Network.TailnetDebug)
require.NotNil(t, bun.Workspace)
assertSanitizedWorkspace(t, bun.Workspace.Workspace)
require.NotEmpty(t, bun.Logs)
assertNotNilNotEmpty(t, bun.Deployment.HealthReport, "deployment health report should be present")
assertNotNilNotEmpty(t, bun.Deployment.Experiments, "deployment experiments should be present")
assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present")
assertNotNilNotEmpty(t, bun.Network.TailnetDebug, "network tailnet debug should be present")
assert.Empty(t, bun.Network.Netcheck, "did not expect netcheck to be present")
assert.Empty(t, bun.Workspace.Workspace, "did not expect workspace to be present")
assert.Empty(t, bun.Agent, "did not expect agent to be present")
assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present")
})
t.Run("NoAuth", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
})
@@ -117,7 +132,7 @@ func TestRun(t *testing.T) {
t.Run("MissingPrivilege", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
})
@@ -200,5 +215,21 @@ func setupWorkspaceAndAgent(ctx context.Context, t *testing.T, client *codersdk.
})
require.NoError(t, err)
tempDir := t.TempDir()
logPath := filepath.Join(tempDir, "coder-agent.log")
require.NoError(t, os.WriteFile(logPath, []byte("hello from the agent"), 0o600))
_ = agenttest.New(t, client.URL, wbr.AgentToken, func(o *agent.Options) {
o.LogDir = tempDir
})
coderdtest.NewWorkspaceAgentWaiter(t, client, wbr.Workspace.ID).Wait()
return ws, agt
}
func assertNotNilNotEmpty[T any](t *testing.T, v T, msg string) {
t.Helper()
if assert.NotNil(t, v, msg+" but was nil") {
assert.NotEmpty(t, v, msg+" but was empty")
}
}