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
View File
@@ -122,6 +122,7 @@ type AgentConn interface {
ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error)
WriteFile(ctx context.Context, path string, reader io.Reader) error
EditFiles(ctx context.Context, edits FileEditRequest) (FileEditResponse, error)
BundleFiles(ctx context.Context, req BundleFilesRequest) ([]byte, error)
SSH(ctx context.Context) (*gonet.TCPConn, error)
SSHClient(ctx context.Context) (*ssh.Client, error)
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
@@ -462,6 +463,73 @@ func (c *agentConn) DebugManifest(ctx context.Context) ([]byte, error) {
return bs, nil
}
// BundleFilesRequest configures a workspace-side file collection.
type BundleFilesRequest struct {
Paths []string `json:"paths"`
}
// BundleFilesManifest is the manifest.json of the archive returned by
// BundleFiles.
type BundleFilesManifest struct {
Requested []string `json:"requested"`
Files []BundleFilesManifestEntry `json:"files"`
Errors []BundleFilesManifestError `json:"errors"`
Truncated bool `json:"truncated"`
Limits BundleFilesLimits `json:"limits"`
}
// BundleFilesManifestEntry describes one file collected into the archive.
type BundleFilesManifestEntry struct {
Requested string `json:"requested"`
Path string `json:"path"`
ArchivePath string `json:"archive_path"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
BytesWritten int64 `json:"bytes_written"`
Truncated bool `json:"truncated"`
}
// BundleFilesManifestError records a path that could not be collected.
type BundleFilesManifestError struct {
Requested string `json:"requested"`
Path string `json:"path,omitempty"`
Reason string `json:"reason"`
}
// BundleFilesLimits are the collection limits the agent applied.
type BundleFilesLimits struct {
MaxFiles int `json:"max_files"`
MaxBytesPerFile int64 `json:"max_bytes_per_file"`
MaxTotalBytes int64 `json:"max_total_bytes"`
}
// bundleFilesResponseMaxBytes guards against a misbehaving agent; the
// agent itself caps collection at 100 MiB.
const bundleFilesResponseMaxBytes int64 = 110 * 1024 * 1024
// BundleFiles returns a tar archive of explicitly requested workspace
// files.
func (c *agentConn) BundleFiles(ctx context.Context, req BundleFilesRequest) ([]byte, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/bundle-files", req)
if err != nil {
return nil, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, codersdk.ReadBodyAsError(res)
}
bs, err := io.ReadAll(io.LimitReader(res.Body, bundleFilesResponseMaxBytes+1))
if err != nil {
return nil, xerrors.Errorf("read response body: %w", err)
}
if int64(len(bs)) > bundleFilesResponseMaxBytes {
return nil, xerrors.Errorf("response exceeds %d bytes", bundleFilesResponseMaxBytes)
}
return bs, nil
}
// DebugLogsOption configures a DebugLogs request.
type DebugLogsOption func(*debugLogsConfig)