mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(support): fetch agent network info over tailnet (#12577)
Adds agent-related information to support bundle command.
This commit is contained in:
+139
-38
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user