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:
Ehab Younes
2026-07-16 13:00:32 +03:00
committed by GitHub
parent f997afa220
commit 35ade9e3d2
19 changed files with 1390 additions and 71 deletions
+68 -15
View File
@@ -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
})
+26
View File
@@ -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)
}
+45
View File
@@ -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()