mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: collect workspace logs in support bundles (#26694)
Add workspace-side file collection to `coder support bundle` via repeatable --workspace-file flags. The agent resolves the requested paths or globs inside the remote workspace and streams back a tar with a manifest and the collected files; nothing is read from the machine running the command. - Add POST /api/v0/bundle-files to the agent's agentfiles package. - Expand env vars in the agent's environment; paths must then be absolute or start with ~/ (the agent user's home directory). - Support ** globs and tail oversized files. - Record requested patterns, per-path errors, truncation, and the applied limits in a manifest. - Unpack the archive into the bundle under agent/workspace_files/, recording dropped entries in collection_errors.txt. - Write a manifest-only archive marking collection as unsupported for agents that predate the endpoint. - Bound collection: 64 KB request body, 10000 files, 10 MiB per file, 100 MiB total including archive overhead, 110 MiB client-side read cap, 5 minute timeout. Closes #26020
This commit is contained in:
+68
-15
@@ -86,17 +86,18 @@ type Workspace struct {
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
Agent *codersdk.WorkspaceAgent `json:"agent"`
|
||||
ConnectionInfo *workspacesdk.AgentConnectionInfo `json:"connection_info"`
|
||||
ListeningPorts *codersdk.WorkspaceAgentListeningPortsResponse `json:"listening_ports"`
|
||||
Logs []byte `json:"logs"`
|
||||
ClientMagicsockHTML []byte `json:"client_magicsock_html"`
|
||||
AgentMagicsockHTML []byte `json:"agent_magicsock_html"`
|
||||
Manifest *agentsdk.Manifest `json:"manifest"`
|
||||
PeerDiagnostics *tailnet.PeerDiagnostics `json:"peer_diagnostics"`
|
||||
PingResult *ipnstate.PingResult `json:"ping_result"`
|
||||
Prometheus []byte `json:"prometheus"`
|
||||
StartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"`
|
||||
Agent *codersdk.WorkspaceAgent `json:"agent"`
|
||||
ConnectionInfo *workspacesdk.AgentConnectionInfo `json:"connection_info"`
|
||||
ListeningPorts *codersdk.WorkspaceAgentListeningPortsResponse `json:"listening_ports"`
|
||||
Logs []byte `json:"logs"`
|
||||
WorkspaceFilesArchive []byte `json:"workspace_files_archive"`
|
||||
ClientMagicsockHTML []byte `json:"client_magicsock_html"`
|
||||
AgentMagicsockHTML []byte `json:"agent_magicsock_html"`
|
||||
Manifest *agentsdk.Manifest `json:"manifest"`
|
||||
PeerDiagnostics *tailnet.PeerDiagnostics `json:"peer_diagnostics"`
|
||||
PingResult *ipnstate.PingResult `json:"ping_result"`
|
||||
Prometheus []byte `json:"prometheus"`
|
||||
StartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"`
|
||||
}
|
||||
|
||||
type TemplateDump struct {
|
||||
@@ -142,6 +143,8 @@ type Deps struct {
|
||||
WorkspacesTotalCap int
|
||||
// TemplateID optionally specifies a template to capture (active version).
|
||||
TemplateID uuid.UUID
|
||||
// WorkspaceFilePatterns are file paths or globs the agent collects from inside the remote workspace.
|
||||
WorkspaceFilePatterns []string
|
||||
// CollectPprof toggles server and agent pprof collection.
|
||||
CollectPprof bool
|
||||
}
|
||||
@@ -536,7 +539,7 @@ 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 {
|
||||
func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, workspaceFilePatterns []string) Agent {
|
||||
var (
|
||||
a Agent
|
||||
eg errgroup.Group
|
||||
@@ -573,7 +576,7 @@ func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, ag
|
||||
|
||||
// to simplify control flow, fetching information directly from
|
||||
// the agent is handled in a separate function
|
||||
closer := connectedAgentInfo(ctx, client, log, agentID, &eg, &a)
|
||||
closer := connectedAgentInfo(ctx, client, log, agentID, workspaceFilePatterns, &eg, &a)
|
||||
defer closer()
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
@@ -583,7 +586,7 @@ func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, ag
|
||||
return a
|
||||
}
|
||||
|
||||
func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, eg *errgroup.Group, a *Agent) (closer func()) {
|
||||
func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, workspaceFilePatterns []string, eg *errgroup.Group, a *Agent) (closer func()) {
|
||||
conn, err := workspacesdk.New(client).
|
||||
DialAgent(ctx, agentID, &workspacesdk.DialAgentOptions{
|
||||
Logger: log.Named("dial-agent"),
|
||||
@@ -675,6 +678,24 @@ func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.L
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(workspaceFilePatterns) > 0 {
|
||||
eg.Go(func() error {
|
||||
workspaceFilesArchive, err := conn.BundleFiles(ctx, workspacesdk.BundleFilesRequest{
|
||||
Paths: workspaceFilePatterns,
|
||||
})
|
||||
if err != nil {
|
||||
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound {
|
||||
log.Warn(ctx, "workspace file collection is unsupported by this agent")
|
||||
a.WorkspaceFilesArchive = unsupportedWorkspaceFilesArchive(workspaceFilePatterns)
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("fetch workspace files: %w", err)
|
||||
}
|
||||
a.WorkspaceFilesArchive = workspaceFilesArchive
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
lps, err := conn.ListeningPorts(ctx)
|
||||
if err != nil {
|
||||
@@ -687,6 +708,38 @@ func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.L
|
||||
return closer
|
||||
}
|
||||
|
||||
// unsupportedWorkspaceFilesArchive builds a manifest-only archive recording
|
||||
// the requested patterns, for agents that predate the bundle-files endpoint.
|
||||
func unsupportedWorkspaceFilesArchive(patterns []string) []byte {
|
||||
manifest, err := json.MarshalIndent(workspacesdk.BundleFilesManifest{
|
||||
Requested: patterns,
|
||||
Errors: []workspacesdk.BundleFilesManifestError{
|
||||
{Reason: "workspace file collection is not supported by this agent version"},
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
err = tw.WriteHeader(&tar.Header{
|
||||
Name: "manifest.json",
|
||||
Mode: 0o644,
|
||||
Size: int64(len(manifest)),
|
||||
ModTime: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := tw.Write(manifest); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func PprofInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) *PprofCollection {
|
||||
if client == nil {
|
||||
return nil
|
||||
@@ -1089,7 +1142,7 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) {
|
||||
return nil
|
||||
})
|
||||
eg.Go(func() error {
|
||||
ai := AgentInfo(ctx, d.Client, d.Log, d.AgentID)
|
||||
ai := AgentInfo(ctx, d.Client, d.Log, d.AgentID, d.WorkspaceFilePatterns)
|
||||
b.Agent = ai
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestUnsupportedWorkspaceFilesArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
patterns := []string{"~/a.log", "~/logs/**/*.log"}
|
||||
entries := testutil.ReadTar(t, unsupportedWorkspaceFilesArchive(patterns))
|
||||
|
||||
var manifest workspacesdk.BundleFilesManifest
|
||||
require.NoError(t, json.Unmarshal(entries["manifest.json"], &manifest))
|
||||
require.Equal(t, patterns, manifest.Requested)
|
||||
require.Len(t, manifest.Errors, 1)
|
||||
require.Contains(t, manifest.Errors[0].Reason, "not supported")
|
||||
require.Empty(t, manifest.Files)
|
||||
require.Len(t, entries, 1)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package support_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/sloghuman"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
@@ -26,6 +28,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/support"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/serpent"
|
||||
@@ -199,6 +202,35 @@ func TestRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunCollectsWorkspaceFiles(t *testing.T) {
|
||||
// The resolved dir matches the agent's canonicalized manifest paths
|
||||
// (the macOS temp dir is a symlink).
|
||||
home := testutil.TempDirResolved(t)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(home, "workspace-service.log"), []byte("workspace service log"), 0o600))
|
||||
|
||||
cfg := coderdtest.DeploymentValues(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: cfg,
|
||||
Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))),
|
||||
})
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
ws, agt := setupWorkspaceAndAgent(ctx, t, client, db, admin)
|
||||
|
||||
bun, err := support.Run(ctx, &support.Deps{
|
||||
Client: client,
|
||||
Log: testutil.Logger(t).Named("bundle"),
|
||||
WorkspaceID: ws.ID,
|
||||
AgentID: agt.ID,
|
||||
WorkspaceFilePatterns: []string{"$HOME/workspace-service.log"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assertWorkspaceFilesArchive(t, bun.Agent.WorkspaceFilesArchive, agentfiles.BundleFilesArchivePath(filepath.Join(home, "workspace-service.log")), "workspace service log")
|
||||
}
|
||||
|
||||
func assertSanitizedDeploymentConfig(t *testing.T, dc *codersdk.DeploymentConfig) {
|
||||
t.Helper()
|
||||
for _, opt := range dc.Options {
|
||||
@@ -282,6 +314,19 @@ func setupWorkspaceAndAgent(ctx context.Context, t *testing.T, client *codersdk.
|
||||
return ws, agt
|
||||
}
|
||||
|
||||
func assertWorkspaceFilesArchive(t *testing.T, data []byte, wantEntry string, wantContent string) {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, data)
|
||||
entries := testutil.ReadTar(t, data)
|
||||
require.Equal(t, wantContent, string(entries[wantEntry]))
|
||||
|
||||
var manifest workspacesdk.BundleFilesManifest
|
||||
require.NoError(t, json.Unmarshal(entries["manifest.json"], &manifest))
|
||||
require.Len(t, manifest.Files, 1)
|
||||
require.Equal(t, wantEntry, manifest.Files[0].ArchivePath)
|
||||
}
|
||||
|
||||
func assertNotNilNotEmpty[T any](t *testing.T, v T, msg string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user