diff --git a/agent/agent.go b/agent/agent.go index 550392802d..981a862d05 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -475,7 +475,7 @@ func (a *agent) init() { a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...) pathStore := agentgit.NewPathStore() - a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore) + a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore, agentfiles.WithEnvInfo(a.envInfo)) a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.filesystem, pathStore, a.envInfo, a.updateCommandEnv, func() string { if m := a.manifest.Load(); m != nil { return m.Directory diff --git a/agent/agentcontext/paths.go b/agent/agentcontext/paths.go index 164a6f7c94..7cc425e5d3 100644 --- a/agent/agentcontext/paths.go +++ b/agent/agentcontext/paths.go @@ -9,8 +9,16 @@ import ( ) // lexicalPath returns raw as a cleaned, absolute path with ~ -// expanded and symlinks left unresolved. +// expanded against the current user's home and symlinks left +// unresolved. func lexicalPath(raw string) (string, error) { + return lexicalPathIn(os.UserHomeDir, raw) +} + +// lexicalPathIn is lexicalPath with home injected. home is called +// only when a ~ prefix needs expanding, so an absolute path resolves +// even when home is unavailable. +func lexicalPathIn(home func() (string, error), raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", xerrors.New("path is empty") @@ -18,14 +26,14 @@ func lexicalPath(raw string) (string, error) { // ~user forms are intentionally unsupported. if raw == "~" || strings.HasPrefix(raw, "~/") { - home, err := os.UserHomeDir() + h, err := home() if err != nil { return "", xerrors.Errorf("expand home dir: %w", err) } if raw == "~" { - raw = home + raw = h } else { - raw = filepath.Join(home, raw[2:]) + raw = filepath.Join(h, raw[2:]) } } @@ -40,7 +48,19 @@ func lexicalPath(raw string) (string, error) { // CanonicalizePath returns lexicalPath with symlinks resolved // when the target exists. func CanonicalizePath(raw string) (string, error) { - cleaned, err := lexicalPath(raw) + return resolveCanonicalPath(os.UserHomeDir, raw) +} + +// CanonicalizePathIn is CanonicalizePath with ~ expanded against +// the given home directory instead of the current user's. +func CanonicalizePathIn(home string, raw string) (string, error) { + return resolveCanonicalPath(func() (string, error) { return home, nil }, raw) +} + +// resolveCanonicalPath implements both CanonicalizePath and +// CanonicalizePathIn. +func resolveCanonicalPath(home func() (string, error), raw string) (string, error) { + cleaned, err := lexicalPathIn(home, raw) if err != nil { return "", err } diff --git a/agent/agentcontext/paths_test.go b/agent/agentcontext/paths_test.go index c737bb338d..bc991c3e6a 100644 --- a/agent/agentcontext/paths_test.go +++ b/agent/agentcontext/paths_test.go @@ -66,6 +66,17 @@ func TestCanonicalizePath_BareTildeExpandsToHome(t *testing.T) { require.Equal(t, want, got) } +func TestCanonicalizePathIn_ExpandsAgainstGivenHome(t *testing.T) { + t.Parallel() + home := testutil.TempDirResolved(t) + got, err := agentcontext.CanonicalizePathIn(home, "~/.coder") + require.NoError(t, err) + require.Equal(t, filepath.Join(home, ".coder"), got) + + _, err = agentcontext.CanonicalizePathIn(home, "relative/path") + require.Error(t, err) +} + func TestCanonicalizePath_FollowsSymlinks(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { diff --git a/agent/agentfiles/api.go b/agent/agentfiles/api.go index e7667b1f81..8c91177352 100644 --- a/agent/agentfiles/api.go +++ b/agent/agentfiles/api.go @@ -8,20 +8,46 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/agent/agentgit" + "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/codersdk/workspacesdk" ) // API exposes file-related operations performed through the agent. type API struct { - logger slog.Logger - filesystem afero.Fs - pathStore *agentgit.PathStore + logger slog.Logger + filesystem afero.Fs + pathStore *agentgit.PathStore + envInfo usershell.EnvInfoer + bundleFilesLimits workspacesdk.BundleFilesLimits } -func NewAPI(logger slog.Logger, filesystem afero.Fs, pathStore *agentgit.PathStore) *API { +// Option configures the API. +type Option func(*API) + +// WithBundleFilesLimits overrides the bundle files collection limits. +func WithBundleFilesLimits(limits workspacesdk.BundleFilesLimits) Option { + return func(api *API) { + api.bundleFilesLimits = limits + } +} + +// WithEnvInfo overrides how the agent user's home directory is resolved. +func WithEnvInfo(envInfo usershell.EnvInfoer) Option { + return func(api *API) { + api.envInfo = envInfo + } +} + +func NewAPI(logger slog.Logger, filesystem afero.Fs, pathStore *agentgit.PathStore, opts ...Option) *API { api := &API{ - logger: logger, - filesystem: filesystem, - pathStore: pathStore, + logger: logger, + filesystem: filesystem, + pathStore: pathStore, + envInfo: usershell.SystemEnvInfo{}, + bundleFilesLimits: defaultBundleFilesLimits, + } + for _, opt := range opts { + opt(api) } return api } @@ -36,6 +62,7 @@ func (api *API) Routes() http.Handler { r.Get("/read-file-lines", api.HandleReadFileLines) r.Post("/write-file", api.HandleWriteFile) r.Post("/edit-files", api.HandleEditFiles) + r.Post("/bundle-files", api.HandleBundleFiles) return r } diff --git a/agent/agentfiles/bundlefiles.go b/agent/agentfiles/bundlefiles.go new file mode 100644 index 0000000000..8fe6cddf66 --- /dev/null +++ b/agent/agentfiles/bundlefiles.go @@ -0,0 +1,394 @@ +package agentfiles + +import ( + "archive/tar" + "context" + "encoding/json" + "errors" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/bmatcuk/doublestar/v4" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +const ( + bundleFilesRequestMaxBytes = 64 * 1024 + // bundleFilesWriteTimeout gives slow links well over the server's 20s + // WriteTimeout to stream the archive. + bundleFilesWriteTimeout = 5 * time.Minute + + tarBlockSize = 512 +) + +// defaultBundleFilesLimits caps a single collection. Tar headers, block +// padding, and manifest file entries are charged against MaxTotalBytes, +// so it approximately bounds the response size. +var defaultBundleFilesLimits = workspacesdk.BundleFilesLimits{ + MaxFiles: 10000, + MaxBytesPerFile: 10 * 1024 * 1024, + MaxTotalBytes: 100 * 1024 * 1024, +} + +var errBundleFilesFileLimit = xerrors.New("bundle files file count limit reached") + +// HandleBundleFiles streams a tar archive of the requested workspace +// files. Environment variables in paths are expanded in the agent's +// environment; paths must then be absolute or start with ~/, which +// resolves against the agent user's home directory. +func (api *API) HandleBundleFiles(w http.ResponseWriter, r *http.Request) { + var req workspacesdk.BundleFilesRequest + r.Body = http.MaxBytesReader(w, r.Body, bundleFilesRequestMaxBytes) + if !httpapi.Read(r.Context(), w, r, &req) { + return + } + + home, err := api.envInfo.HomeDir() + if err != nil { + api.logger.Error(r.Context(), "get user home dir", slog.Error(err)) + httpapi.InternalServerError(w, xerrors.Errorf("get user home dir: %w", err)) + return + } + + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(bundleFilesWriteTimeout)); err != nil { + api.logger.Warn(r.Context(), "extend bundle files write deadline", slog.Error(err)) + } + + clientCtx := r.Context() + ctx, cancel := context.WithTimeout(clientCtx, bundleFilesWriteTimeout) + defer cancel() + + w.Header().Set("Content-Type", "application/x-tar") + w.WriteHeader(http.StatusOK) + if err := collectBundleFiles(ctx, clientCtx, home, req, w, api.bundleFilesLimits); err != nil { + api.logger.Error(clientCtx, "collect bundle files", slog.Error(err)) + } +} + +// collectBundleFiles streams a tar with the requested files under files/ +// and a manifest.json describing the collection. Per-path problems are +// recorded in the manifest, not fatal. ctx bounds the collection; +// clientCtx is the request context. +func collectBundleFiles(ctx, clientCtx context.Context, home string, req workspacesdk.BundleFilesRequest, w io.Writer, limits workspacesdk.BundleFilesLimits) error { + manifest := workspacesdk.BundleFilesManifest{Requested: req.Paths, Limits: limits} + paths := req.Paths + + home, err := filepath.Abs(home) + if err != nil { + // Collect nothing; the archive still carries the manifest. + appendManifestError(&manifest, "", "", "resolve home directory: "+err.Error()) + paths = nil + } + + tw := tar.NewWriter(w) + c := &bundleFilesCollector{ + tw: tw, + clientCtx: clientCtx, + home: home, + limits: limits, + manifest: &manifest, + seenPaths: map[string]struct{}{}, + remainingBytes: limits.MaxTotalBytes, + } + for _, requested := range paths { + if !c.collectPattern(ctx, requested) { + break + } + } + if clientCtx.Err() != nil { + // The client is gone; there is nobody to receive the manifest. + return xerrors.Errorf("client disconnected: %w", clientCtx.Err()) + } + + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return xerrors.Errorf("marshal manifest: %w", err) + } + err = tw.WriteHeader(&tar.Header{ + Name: "manifest.json", + Mode: 0o644, + Size: int64(len(manifestJSON)), + ModTime: time.Now(), + }) + if err != nil { + return xerrors.Errorf("create manifest in archive: %w", err) + } + if _, err := tw.Write(manifestJSON); err != nil { + return xerrors.Errorf("write manifest: %w", err) + } + if err := tw.Close(); err != nil { + return xerrors.Errorf("close archive: %w", err) + } + return nil +} + +// bundleFilesCollector streams matched files into an archive while +// enforcing limits and recording per-path problems in the manifest. +// Collect methods return false once a global limit ends collection. +type bundleFilesCollector struct { + tw *tar.Writer + clientCtx context.Context + home string + limits workspacesdk.BundleFilesLimits + manifest *workspacesdk.BundleFilesManifest + seenPaths map[string]struct{} + remainingBytes int64 + filesWritten int +} + +func (c *bundleFilesCollector) collectPattern(ctx context.Context, requested string) bool { + if ctx.Err() != nil { + return c.stopCanceled(requested, "") + } + if c.filesWritten >= c.limits.MaxFiles { + return c.stop(requested, "", "file count limit reached") + } + + matches, matchesTruncated, err := bundleFileMatches(ctx, c.home, requested, c.limits.MaxFiles-c.filesWritten) + if err != nil { + if ctx.Err() != nil { + return c.stopCanceled(requested, "") + } + appendManifestError(c.manifest, requested, "", err.Error()) + return true + } + if len(matches) == 0 { + appendManifestError(c.manifest, requested, "", "no matches") + return true + } + if matchesTruncated { + c.manifest.Truncated = true + appendManifestError(c.manifest, requested, "", "file count limit reached") + } + + for _, abs := range matches { + if !c.collectFile(ctx, requested, abs) { + return false + } + } + return true +} + +func (c *bundleFilesCollector) collectFile(ctx context.Context, requested string, abs string) bool { + if ctx.Err() != nil { + return c.stopCanceled(requested, abs) + } + if c.filesWritten >= c.limits.MaxFiles { + return c.stop(requested, abs, "file count limit reached") + } + // Each entry costs a tar header block before any data fits. + if c.remainingBytes <= tarBlockSize { + return c.stop(requested, abs, "total byte limit reached") + } + if _, ok := c.seenPaths[abs]; ok { + return true + } + c.seenPaths[abs] = struct{}{} + + // Stat before open: opening a FIFO would block. Stat follows symlinks, + // so a directly requested symlink collects its target. + info, err := os.Stat(abs) + if err != nil { + reason := "stat path: " + err.Error() + if errors.Is(err, fs.ErrNotExist) { + reason = "does not exist" + } + appendManifestError(c.manifest, requested, abs, reason) + return true + } + if !info.Mode().IsRegular() { + appendManifestError(c.manifest, requested, abs, "not a regular file: "+fileModeTypeName(info.Mode())) + return true + } + + bytesToWrite := min(info.Size(), c.limits.MaxBytesPerFile, c.remainingBytes-tarBlockSize) + entry := workspacesdk.BundleFilesManifestEntry{ + Requested: requested, + Path: abs, + ArchivePath: BundleFilesArchivePath(abs), + Size: info.Size(), + ModTime: info.ModTime(), + BytesWritten: bytesToWrite, + Truncated: bytesToWrite < info.Size(), + } + c.manifest.Truncated = c.manifest.Truncated || entry.Truncated + if err := writeBundleFileEntry(c.tw, abs, entry); err != nil { + appendManifestError(c.manifest, requested, abs, err.Error()) + return true + } + c.manifest.Files = append(c.manifest.Files, entry) + // The last file may overshoot the budget by under a block; the bound + // is approximate, not exact. + entryJSON, _ := json.Marshal(entry) + c.remainingBytes -= tarEntrySize(bytesToWrite) + int64(len(entryJSON)) + c.filesWritten++ + return true +} + +// stop marks the manifest truncated, records the reason, and halts +// collection. +func (c *bundleFilesCollector) stop(requested string, filePath string, reason string) bool { + c.manifest.Truncated = true + appendManifestError(c.manifest, requested, filePath, reason) + return false +} + +// stopCanceled halts collection after the collection context ended: a +// timeout is recorded in the manifest and the archive is finished, while a +// client disconnect makes the caller abort without a manifest. +func (c *bundleFilesCollector) stopCanceled(requested string, filePath string) bool { + if c.clientCtx.Err() != nil { + return false + } + return c.stop(requested, filePath, "exceeded maximum collection time") +} + +// bundleFileMatches expands requested against home and returns matching +// cleaned absolute paths. Non-glob paths return a single candidate without +// checking existence; the caller reports missing files on stat. +func bundleFileMatches(ctx context.Context, home string, requested string, maxMatches int) ([]string, bool, error) { + // Env vars expand from the agent environment and ~ resolves against + // the agent home, matching the agent's expandPathToAbs. Glob patterns + // never exist on disk, so canonicalization keeps them lexical. + abs, err := agentcontext.CanonicalizePathIn(home, os.ExpandEnv(requested)) + if err != nil { + return nil, false, err + } + if !strings.ContainsAny(abs, "*?{[") { + return []string{abs}, false, nil + } + + base, pattern := doublestar.SplitPattern(filepath.ToSlash(abs)) + matches := make([]string, 0, min(maxMatches, 64)) + // WithNoFollow avoids symlink cycles. Checking the limit before the + // append keeps matches from growing past maxMatches. + err = doublestar.GlobWalk(bundleFilesFS{ctx: ctx, fsys: os.DirFS(base)}, pattern, func(match string, _ fs.DirEntry) error { + if len(matches) >= maxMatches { + return errBundleFilesFileLimit + } + matches = append(matches, filepath.Join(base, filepath.FromSlash(match))) + return nil + }, doublestar.WithFilesOnly(), doublestar.WithNoFollow()) + matchesTruncated := errors.Is(err, errBundleFilesFileLimit) + if err != nil && !matchesTruncated { + return nil, false, xerrors.Errorf("glob pattern: %w", err) + } + // doublestar does not guarantee ordering, so sort for a deterministic + // archive. + slices.Sort(matches) + return matches, matchesTruncated, nil +} + +// bundleFilesFS cancels a glob walk once the request context ends. Only +// Open is implemented; the fs.ReadDir and fs.Stat helpers fall back to it, +// so every filesystem operation of the walk passes the context check. +type bundleFilesFS struct { + ctx context.Context + fsys fs.FS +} + +func (f bundleFilesFS) Open(name string) (fs.File, error) { + if err := f.ctx.Err(); err != nil { + return nil, err + } + return f.fsys.Open(name) +} + +// BundleFilesArchivePath maps a cleaned absolute path to its archive entry +// name: files/ plus the path with the leading separator trimmed and any +// Windows drive colon dropped, keeping the name fs.ValidPath-safe. +func BundleFilesArchivePath(abs string) string { + p := strings.TrimPrefix(filepath.ToSlash(abs), "/") + if len(p) >= 2 && p[1] == ':' { + p = p[:1] + p[2:] + } + return "files/" + p +} + +// fileModeTypeName names the type of a non-regular file. +func fileModeTypeName(mode fs.FileMode) string { + switch { + case mode.IsDir(): + return "directory" + case mode&fs.ModeSymlink != 0: + return "symlink" + case mode&fs.ModeNamedPipe != 0: + return "named pipe" + case mode&fs.ModeSocket != 0: + return "socket" + case mode&fs.ModeDevice != 0, mode&fs.ModeCharDevice != 0: + return "device" + default: + return "irregular file" + } +} + +// writeBundleFileEntry writes the last entry.BytesWritten bytes of the +// file at abs to the archive at entry.ArchivePath. A file that shrinks +// after stat is zero-padded to the declared size, since a short entry +// would corrupt every entry after it; the short read is still an error. +func writeBundleFileEntry(tw *tar.Writer, abs string, entry workspacesdk.BundleFilesManifestEntry) error { + f, err := os.Open(abs) + if err != nil { + return xerrors.Errorf("open file: %w", err) + } + defer f.Close() + + if entry.BytesWritten < entry.Size { + if _, err := f.Seek(entry.Size-entry.BytesWritten, io.SeekStart); err != nil { + return xerrors.Errorf("seek tail: %w", err) + } + } + err = tw.WriteHeader(&tar.Header{ + Name: entry.ArchivePath, + Mode: 0o644, + Size: entry.BytesWritten, + ModTime: entry.ModTime, + }) + if err != nil { + return xerrors.Errorf("create archive entry: %w", err) + } + n, err := io.Copy(tw, io.LimitReader(f, entry.BytesWritten)) + if err == nil && n < entry.BytesWritten { + err = io.ErrUnexpectedEOF + } + if err != nil { + if _, padErr := io.CopyN(tw, zeroReader{}, entry.BytesWritten-n); padErr != nil { + return xerrors.Errorf("pad short entry: %w", padErr) + } + return xerrors.Errorf("copy file: %w", err) + } + return nil +} + +// tarEntrySize returns the archive bytes a file entry consumes: one +// header block plus the data rounded up to whole blocks. +func tarEntrySize(dataBytes int64) int64 { + return tarBlockSize + (dataBytes+tarBlockSize-1)/tarBlockSize*tarBlockSize +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + clear(p) + return len(p), nil +} + +func appendManifestError(m *workspacesdk.BundleFilesManifest, requested string, filePath string, reason string) { + m.Errors = append(m.Errors, workspacesdk.BundleFilesManifestError{ + Requested: requested, + Path: filePath, + Reason: reason, + }) +} diff --git a/agent/agentfiles/bundlefiles_test.go b/agent/agentfiles/bundlefiles_test.go new file mode 100644 index 0000000000..8c959c17f5 --- /dev/null +++ b/agent/agentfiles/bundlefiles_test.go @@ -0,0 +1,246 @@ +package agentfiles_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentfiles" + "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/testutil" +) + +func TestBundleFilesCollectsExpandedPathsAndGlobs(t *testing.T) { + t.Parallel() + + home := testutil.TempDirResolved(t) + writeBundleSourceFile(t, home, ".vscode-server/data/logs/20260706T101112/remoteagent.log", "remote agent") + writeBundleSourceFile(t, home, ".vscode-server/data/logs/20260706T101112/exthost1/exthost.log", "exthost") + writeBundleSourceFile(t, home, ".vscode-server/data/logs/20260706T101112/exthost1/output.txt", "skip") + writeBundleSourceFile(t, home, ".local/share/code-server/coder-logs/app.log", "code server log") + writeBundleSourceFile(t, home, ".cache/JetBrains/RemoteDev/dist/241.15989.150/log/idea.log", "idea log") + writeBundleSourceFile(t, home, "brace/one.log", "one") + writeBundleSourceFile(t, home, "brace/two.txt", "two") + writeBundleSourceFile(t, home, "brace/skip.json", "skip") + + entries := readBundleFilesArchive(t, requestBundleFiles(t, newBundleFilesHandler(t, home), []string{ + filepath.Join(home, ".vscode-server/data/logs/20260706T101112/remoteagent.log"), + "~/.vscode-server/data/logs/**/*.log", + "~/.local/share/code-server/coder-logs/app.log", + "~/.cache/JetBrains/RemoteDev/dist/*/log/idea.log", + "~/brace/*.{log,txt}", + })) + + requireBundleEntry(t, entries, home, ".vscode-server/data/logs/20260706T101112/remoteagent.log", "remote agent") + requireBundleEntry(t, entries, home, ".vscode-server/data/logs/20260706T101112/exthost1/exthost.log", "exthost") + requireBundleEntry(t, entries, home, ".local/share/code-server/coder-logs/app.log", "code server log") + requireBundleEntry(t, entries, home, ".cache/JetBrains/RemoteDev/dist/241.15989.150/log/idea.log", "idea log") + requireBundleEntry(t, entries, home, "brace/one.log", "one") + requireBundleEntry(t, entries, home, "brace/two.txt", "two") + require.NotContains(t, entries.files, bundleArchivePath(t, home, ".vscode-server/data/logs/20260706T101112/exthost1/output.txt")) + require.NotContains(t, entries.files, bundleArchivePath(t, home, "brace/skip.json")) + require.Empty(t, entries.manifest.Errors) + // remoteagent.log matches both the absolute path and the ** glob; it + // must be archived once. + require.Len(t, entries.manifest.Files, 6) +} + +func TestBundleFilesCollectsAbsolutePathsOutsideHome(t *testing.T) { + t.Parallel() + + home := testutil.TempDirResolved(t) + outside := testutil.TempDirResolved(t) + writeBundleSourceFile(t, outside, "service.log", "outside log") + writeBundleSourceFile(t, outside, "glob/a.log", "glob a") + + entries := readBundleFilesArchive(t, requestBundleFiles(t, newBundleFilesHandler(t, home), []string{ + filepath.Join(outside, "service.log"), + filepath.Join(outside, "glob", "*.log"), + })) + + requireBundleEntry(t, entries, outside, "service.log", "outside log") + requireBundleEntry(t, entries, outside, "glob/a.log", "glob a") + require.Empty(t, entries.manifest.Errors) + require.Len(t, entries.manifest.Files, 2) +} + +func TestBundleFilesRejectedPathsAreNonFatal(t *testing.T) { + t.Parallel() + + home := testutil.TempDirResolved(t) + writeBundleSourceFile(t, home, "kept.log", "kept") + require.NoError(t, os.MkdirAll(filepath.Join(home, "somedir"), 0o700)) + + entries := readBundleFilesArchive(t, requestBundleFiles(t, newBundleFilesHandler(t, home), []string{ + "~/kept.log", + "relative.log", + "~/missing.log", + "~/somedir", + "~/no-matches/**/*.log", + })) + + requireBundleEntry(t, entries, home, "kept.log", "kept") + require.Len(t, entries.manifest.Files, 1) + requireBundleFilesManifestErrors(t, entries.manifest.Errors, + "is not absolute", + "does not exist", + "not a regular file: directory", + "no matches", + ) +} + +func TestBundleFilesTailBytesTruncation(t *testing.T) { + t.Parallel() + + home := testutil.TempDirResolved(t) + writeBundleSourceFile(t, home, "large.log", "0123456789") + + entries := readBundleFilesArchive(t, requestBundleFiles(t, newBundleFilesHandler(t, home, agentfiles.WithBundleFilesLimits(workspacesdk.BundleFilesLimits{ + MaxFiles: 10, + MaxBytesPerFile: 4, + MaxTotalBytes: 100 * 1024, + })), []string{"~/large.log"})) + + requireBundleEntry(t, entries, home, "large.log", "6789") + require.Len(t, entries.manifest.Files, 1) + require.True(t, entries.manifest.Files[0].Truncated) + require.Equal(t, int64(10), entries.manifest.Files[0].Size) + require.Equal(t, int64(4), entries.manifest.Files[0].BytesWritten) +} + +func TestBundleFilesFileAndByteLimits(t *testing.T) { + t.Parallel() + + home := testutil.TempDirResolved(t) + writeBundleSourceFile(t, home, "one.log", "1111") + writeBundleSourceFile(t, home, "two.log", "2222") + writeBundleSourceFile(t, home, "three.log", "3333") + + entries := readBundleFilesArchive(t, requestBundleFiles(t, newBundleFilesHandler(t, home, agentfiles.WithBundleFilesLimits(workspacesdk.BundleFilesLimits{ + MaxFiles: 1, + MaxBytesPerFile: 100, + // One 512-byte tar header plus 3 data bytes: the first file is + // truncated to 3 bytes by the total budget. + MaxTotalBytes: 515, + })), []string{"~/*.log"})) + + require.Len(t, entries.files, 1) + require.True(t, entries.manifest.Truncated) + require.Equal(t, int64(3), entries.manifest.Files[0].BytesWritten) + // The glob walk itself stops at the file cap. + requireBundleFilesManifestErrors(t, entries.manifest.Errors, "file count limit reached") +} + +func TestBundleFilesDedupeByCleanedPath(t *testing.T) { + t.Parallel() + + home := testutil.TempDirResolved(t) + writeBundleSourceFile(t, home, "dup.log", "one") + writeBundleSourceFile(t, home, "other.log", "two") + + entries := readBundleFilesArchive(t, requestBundleFiles(t, newBundleFilesHandler(t, home), []string{ + "~/dup.log", + "~/./dup.log", + filepath.Join(home, "somedir", "..", "dup.log"), + "~/other.log", + })) + + requireBundleEntry(t, entries, home, "dup.log", "one") + requireBundleEntry(t, entries, home, "other.log", "two") + require.Len(t, entries.manifest.Files, 2) +} + +// fakeBundleEnvInfo overrides the home directory so tests can point path +// expansion at a temp dir. +type fakeBundleEnvInfo struct { + usershell.SystemEnvInfo + home string +} + +func (e fakeBundleEnvInfo) HomeDir() (string, error) { + return e.home, nil +} + +func newBundleFilesHandler(t *testing.T, home string, opts ...agentfiles.Option) http.Handler { + t.Helper() + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + opts = append([]agentfiles.Option{agentfiles.WithEnvInfo(fakeBundleEnvInfo{home: home})}, opts...) + return agentfiles.NewAPI(logger, afero.NewOsFs(), nil, opts...).Routes() +} + +func requestBundleFiles(t *testing.T, handler http.Handler, paths []string) []byte { + t.Helper() + + body, err := json.Marshal(workspacesdk.BundleFilesRequest{Paths: paths}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/bundle-files", bytes.NewReader(body)) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, "application/x-tar", res.Header().Get("Content-Type")) + return res.Body.Bytes() +} + +func writeBundleSourceFile(t *testing.T, dir string, rel string, content string) { + t.Helper() + + path := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +// bundleArchivePath returns the expected archive entry name for the file +// at dir/rel. +func bundleArchivePath(t *testing.T, dir string, rel string) string { + t.Helper() + + return agentfiles.BundleFilesArchivePath(filepath.Join(dir, filepath.FromSlash(rel))) +} + +func requireBundleEntry(t *testing.T, entries bundleFilesArchive, dir string, rel string, content string) { + t.Helper() + + require.Equal(t, content, string(entries.files[bundleArchivePath(t, dir, rel)])) +} + +type bundleFilesArchive struct { + manifest workspacesdk.BundleFilesManifest + files map[string][]byte +} + +func readBundleFilesArchive(t *testing.T, data []byte) bundleFilesArchive { + t.Helper() + + entries := bundleFilesArchive{files: testutil.ReadTar(t, data)} + manifestJSON, ok := entries.files["manifest.json"] + require.True(t, ok, "archive should contain manifest.json") + delete(entries.files, "manifest.json") + require.NoError(t, json.Unmarshal(manifestJSON, &entries.manifest)) + require.NotEmpty(t, entries.manifest.Requested) + return entries +} + +func requireBundleFilesManifestErrors(t *testing.T, errs []workspacesdk.BundleFilesManifestError, contains ...string) { + t.Helper() + + for _, want := range contains { + found := slices.ContainsFunc(errs, func(e workspacesdk.BundleFilesManifestError) bool { + return strings.Contains(e.Reason, want) + }) + require.Truef(t, found, "expected manifest error containing %q in %#v", want, errs) + } +} diff --git a/cli/support.go b/cli/support.go index 3269b524ee..2338c24576 100644 --- a/cli/support.go +++ b/cli/support.go @@ -1,15 +1,20 @@ package cli import ( + "archive/tar" "archive/zip" "bytes" "context" "encoding/base64" "encoding/json" + "errors" "fmt" + "io" + "io/fs" "net/http" "net/url" "os" + "path" "path/filepath" "strings" "text/tabwriter" @@ -41,8 +46,9 @@ func (r *RootCmd) support() *serpent.Command { return supportCmd } -var supportBundleBlurb = cliui.Bold("This will collect the following information:\n") + - ` - Coder deployment version +func supportBundleBlurb(workspaceFilePatterns []string) string { + blurb := cliui.Bold("This will collect the following information:\n") + + ` - Coder deployment version - Coder deployment Configuration (sanitized), including enabled experiments - Coder deployment health snapshot - Coder deployment stats (aggregated workspace/session metrics) @@ -55,20 +61,29 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information - Agent details (with environment variable sanitized) - Agent network diagnostics - Agent logs - - License status +` + if len(workspaceFilePatterns) > 0 { + blurb += " - Workspace files matching:\n" + for _, pattern := range workspaceFilePatterns { + blurb += " - " + pattern + "\n" + } + } + return blurb + ` - License status - pprof profiling data (if --pprof is enabled) ` + cliui.Bold("Note: ") + - cliui.Wrap("While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n") + - cliui.Bold("Please confirm that you will:\n") + - " - Review the support bundle before distribution\n" + - " - Only distribute it via trusted channels\n" + - cliui.Bold("Continue? ") + cliui.Wrap("While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n") + + cliui.Bold("Please confirm that you will:\n") + + " - Review the support bundle before distribution\n" + + " - Only distribute it via trusted channels\n" + + cliui.Bold("Continue? ") +} func (r *RootCmd) supportBundle() *serpent.Command { var outputPath string var coderURLOverride string var workspacesTotalCap64 int64 = 10 var templateName string + var workspaceFilePatterns []string var pprof bool cmd := &serpent.Command{ Use: "bundle [] []", @@ -89,7 +104,7 @@ func (r *RootCmd) supportBundle() *serpent.Command { cliLog = cliLog.AppendSinks(sloghuman.Sink(inv.Stderr)) } ans, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: supportBundleBlurb, + Text: supportBundleBlurb(workspaceFilePatterns), Secret: false, IsConfirm: true, }) @@ -249,12 +264,13 @@ func (r *RootCmd) supportBundle() *serpent.Command { deps := support.Deps{ Client: client, // Support adds a sink so we don't need to supply one ourselves. - Log: clientLog, - WorkspaceID: wsID, - AgentID: agtID, - WorkspacesTotalCap: int(workspacesTotalCap64), - TemplateID: templateID, - CollectPprof: pprof, + Log: clientLog, + WorkspaceID: wsID, + AgentID: agtID, + WorkspacesTotalCap: int(workspacesTotalCap64), + TemplateID: templateID, + WorkspaceFilePatterns: workspaceFilePatterns, + CollectPprof: pprof, } bun, err := support.Run(inv.Context(), &deps) @@ -302,6 +318,12 @@ func (r *RootCmd) supportBundle() *serpent.Command { Description: "Template name to include in the support bundle. Use org_name/template_name if template name is reused across multiple organizations.", Value: serpent.StringOf(&templateName), }, + { + Flag: "workspace-file", + Env: "CODER_SUPPORT_BUNDLE_WORKSPACE_FILE", + Description: "File path or glob to collect from inside the remote workspace. Environment variables are expanded in the workspace; paths must then be absolute or start with ~/, which resolves against the agent user's home directory. Files local to the machine running this command are not collected. Can be specified multiple times.", + Value: serpent.StringArrayOf(&workspaceFilePatterns), + }, { Flag: "pprof", Env: "CODER_SUPPORT_BUNDLE_PPROF", @@ -549,6 +571,10 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { } } + if err := writeWorkspaceFilesArchive(src.Agent.WorkspaceFilesArchive, dest, supportBundleWorkspaceFilesMaxBytes); err != nil { + return xerrors.Errorf("write workspace files: %w", err) + } + // Write pprof binary data if err := writePprofData(src.Pprof, dest); err != nil { return xerrors.Errorf("write pprof data: %w", err) @@ -560,6 +586,91 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { return nil } +// supportBundleWorkspaceFilesMaxBytes guards against a misbehaving agent; +// the agent itself caps collection at 100 MiB. +const supportBundleWorkspaceFilesMaxBytes int64 = 110 * 1024 * 1024 + +// writeWorkspaceFilesArchive unpacks the agent's tar into the bundle under +// agent/workspace_files/; dropped entries are recorded in collection_errors.txt. +func writeWorkspaceFilesArchive(src []byte, dest *zip.Writer, maxBytes int64) error { + if len(src) == 0 { + return nil + } + tr := tar.NewReader(bytes.NewReader(src)) + remaining := maxBytes + var skipped []string + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + // A malformed archive shouldn't sink the rest of the bundle. + skipped = append(skipped, fmt.Sprintf("read workspace files archive: %s", err)) + break + } + name, ok := safeWorkspaceFilesArchiveEntryName(hdr.Name) + if !ok || hdr.Typeflag != tar.TypeReg { + skipped = append(skipped, fmt.Sprintf("%s: unexpected entry", hdr.Name)) + continue + } + if hdr.Size > remaining { + // Only a misbehaving agent exceeds the budget; stop trusting + // the rest of the archive. + skipped = append(skipped, fmt.Sprintf("%s: %d bytes exceeds remaining %d byte budget, aborting", name, hdr.Size, remaining)) + break + } + // A failed create means the output zip itself is broken. + f, err := dest.Create(path.Join("agent/workspace_files", name)) + if err != nil { + return xerrors.Errorf("create workspace files entry %q: %w", name, err) + } + // io.CopyN bounds the copy at hdr.Size so a header lying about + // size cannot make us read past the entry; copy failures are + // recorded, not fatal. + n, err := io.CopyN(f, tr, hdr.Size) + remaining -= n + if errors.Is(err, io.EOF) { + err = nil + } + if err != nil { + skipped = append(skipped, fmt.Sprintf("%s: copy: %s (entry may be truncated)", name, err)) + } + } + return writeWorkspaceFilesCollectionErrors(dest, skipped) +} + +// writeWorkspaceFilesCollectionErrors records dropped workspace file entries in the +// bundle instead of failing it. +func writeWorkspaceFilesCollectionErrors(dest *zip.Writer, skipped []string) error { + if len(skipped) == 0 { + return nil + } + f, err := dest.Create("agent/workspace_files/collection_errors.txt") + if err != nil { + return xerrors.Errorf("create workspace files errors: %w", err) + } + body := "# workspace file entries dropped while assembling the support bundle\n" + + strings.Join(skipped, "\n") + "\n" + if _, err := f.Write([]byte(body)); err != nil { + return xerrors.Errorf("write workspace files errors: %w", err) + } + return nil +} + +// safeWorkspaceFilesArchiveEntryName returns name when it is safe to embed in +// the bundle: a valid slash path within the expected layout. Backslashes +// are rejected; some Windows extractors treat them as separators. +func safeWorkspaceFilesArchiveEntryName(name string) (string, bool) { + if strings.Contains(name, `\`) || !fs.ValidPath(name) { + return "", false + } + if name != "manifest.json" && !strings.HasPrefix(name, "files/") { + return "", false + } + return name, true +} + func writePprofData(pprof support.Pprof, dest *zip.Writer) error { // Write server pprof data directly to pprof directory if pprof.Server != nil { diff --git a/cli/support_internal_test.go b/cli/support_internal_test.go new file mode 100644 index 0000000000..8a2db333c1 --- /dev/null +++ b/cli/support_internal_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "archive/tar" + "archive/zip" + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/testutil" +) + +func TestSafeWorkspaceFilesArchiveEntryName(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + ok bool + }{ + {name: "manifest.json", ok: true}, + {name: "files/server.log", ok: true}, + {name: "./files/server.log", ok: false}, + {name: "../manifest.json", ok: false}, + {name: "/manifest.json", ok: false}, + {name: "files/nested/../server.log", ok: false}, + {name: "files/../../manifest.json", ok: false}, + {name: "files\\nested\\server.log", ok: false}, + {name: `files/nested\..\server.log`, ok: false}, + {name: "other/server.log", ok: false}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := safeWorkspaceFilesArchiveEntryName(tt.name) + require.Equal(t, tt.ok, ok) + if tt.ok { + require.Equal(t, tt.name, got) + } + }) + } +} + +func TestWriteWorkspaceFilesArchive(t *testing.T) { + t.Parallel() + + t.Run("UnpacksManifestAndFiles", func(t *testing.T) { + t.Parallel() + + agentArchive := makeWorkspaceFilesArchive(t, + "files/server.log", "server log", + "manifest.json", `{"files":[{"archive_path":"files/server.log"}]}`, + "../escape.log", "should be dropped and recorded", + ) + + var bundle bytes.Buffer + bundleZip := zip.NewWriter(&bundle) + require.NoError(t, writeWorkspaceFilesArchive(agentArchive, bundleZip, supportBundleWorkspaceFilesMaxBytes)) + require.NoError(t, bundleZip.Close()) + + entries := testutil.ReadZip(t, bundle.Bytes()) + require.Equal(t, "server log", string(entries["agent/workspace_files/files/server.log"])) + require.Contains(t, entries, "agent/workspace_files/manifest.json") + require.Contains(t, string(entries["agent/workspace_files/collection_errors.txt"]), "../escape.log") + require.Len(t, entries, 3) + }) + + t.Run("AbortsOnEntryBeyondBudget", func(t *testing.T) { + t.Parallel() + + agentArchive := makeWorkspaceFilesArchive(t, + "files/ok.log", "ok", + "files/big.log", "this entry is too big", + "files/after.log", "never reached", + ) + + var bundle bytes.Buffer + bundleZip := zip.NewWriter(&bundle) + // A 4 byte budget fits ok.log; big.log exceeds it and aborts the + // rest. + require.NoError(t, writeWorkspaceFilesArchive(agentArchive, bundleZip, 4)) + require.NoError(t, bundleZip.Close()) + + entries := testutil.ReadZip(t, bundle.Bytes()) + require.Equal(t, "ok", string(entries["agent/workspace_files/files/ok.log"])) + require.NotContains(t, entries, "agent/workspace_files/files/big.log") + require.NotContains(t, entries, "agent/workspace_files/files/after.log") + errs := string(entries["agent/workspace_files/collection_errors.txt"]) + require.Contains(t, errs, "files/big.log") + require.Contains(t, errs, "budget") + }) + + t.Run("MalformedArchiveDoesNotFail", func(t *testing.T) { + t.Parallel() + + var bundle bytes.Buffer + bundleZip := zip.NewWriter(&bundle) + require.NoError(t, writeWorkspaceFilesArchive([]byte("not a tar"), bundleZip, supportBundleWorkspaceFilesMaxBytes)) + require.NoError(t, bundleZip.Close()) + + entries := testutil.ReadZip(t, bundle.Bytes()) + require.Contains(t, string(entries["agent/workspace_files/collection_errors.txt"]), "read workspace files archive") + }) +} + +// makeWorkspaceFilesArchive tars alternating name/content pairs in order. +func makeWorkspaceFilesArchive(t *testing.T, pairs ...string) []byte { + t.Helper() + + require.Zero(t, len(pairs)%2) + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for i := 0; i < len(pairs); i += 2 { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: pairs[i], + Mode: 0o644, + Size: int64(len(pairs[i+1])), + })) + _, err := tw.Write([]byte(pairs[i+1])) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + return buf.Bytes() +} diff --git a/cli/support_test.go b/cli/support_test.go index f1c8632b84..216f5c00df 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -21,6 +22,7 @@ import ( "tailscale.com/ipn/ipnstate" "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/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -306,6 +308,80 @@ func TestSupportBundle(t *testing.T) { }) } +func TestSupportBundleCollectsWorkspaceFiles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("for some reason, windows fails to remove tempdirs sometimes") + } + + var dc codersdk.DeploymentConfig + dc.Values = coderdtest.DeploymentValues(t) + dc.Values.Prometheus.Enable = true + secretValue := uuid.NewString() + seedSecretDeploymentOptions(t, &dc, secretValue) + client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: dc.Values, + HealthcheckFunc: func(_ context.Context, _ string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport { + return &healthsdk.HealthcheckReport{ + Time: time.Now(), + Healthy: true, + Severity: health.SeverityOK, + } + }, + }) + t.Cleanup(func() { closer.Close() }) + owner := coderdtest.CreateFirstUser(t, client) + workspaceWithAgent := setupSupportBundleTestFixture(testutil.Context(t, testutil.WaitLong), t, api.Database, owner.OrganizationID, owner.UserID, func(agents []*proto.Agent) []*proto.Agent { + agents[0].Env["SECRET_VALUE"] = secretValue + return agents + }) + + // The agent resolves requested paths against $HOME (USERPROFILE on + // Windows). The agent log dir is separate so collection does not race + // live agent logs. 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.MkdirAll(filepath.Join(home, "testlogs", "nested"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, "testlogs", "server.log"), []byte("server log"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(home, "testlogs", "nested", "nested.log"), []byte("nested log"), 0o600)) + + logDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(logDir, "coder-agent.log"), []byte("hello from the agent"), 0o600)) + agt := agenttest.New(t, client.URL, workspaceWithAgent.AgentToken, func(o *agent.Options) { + o.LogDir = logDir + }) + defer agt.Close() + coderdtest.NewWorkspaceAgentWaiter(t, client, workspaceWithAgent.Workspace.ID).Wait() + + d := t.TempDir() + bundlePath := filepath.Join(d, "bundle.zip") + // The exact path and the glob both match server.log to cover + // deduplication end to end. + inv, root := clitest.New(t, + "support", "bundle", workspaceWithAgent.Workspace.Name, + "--workspace-file", "$HOME/testlogs/server.log", + "--workspace-file", "$HOME/testlogs/**/*.log", + "--output-file", bundlePath, + "--yes", + ) + // nolint: gocritic // requires owner privilege + clitest.SetupConfig(t, client, root) + err := inv.WithContext(testutil.Context(t, testutil.WaitLong)).Run() + require.NoError(t, err) + + assertBundleContents(t, bundlePath, true, true, []string{secretValue}) + entries := readZipEntries(t, bundlePath) + serverLogEntry := "agent/workspace_files/" + agentfiles.BundleFilesArchivePath(filepath.Join(home, "testlogs", "server.log")) + nestedLogEntry := "agent/workspace_files/" + agentfiles.BundleFilesArchivePath(filepath.Join(home, "testlogs", "nested", "nested.log")) + require.Equal(t, "server log", string(entries[serverLogEntry])) + require.Equal(t, "nested log", string(entries[nestedLogEntry])) + var manifest workspacesdk.BundleFilesManifest + require.NoError(t, json.Unmarshal(entries["agent/workspace_files/manifest.json"], &manifest)) + require.Equal(t, []string{"$HOME/testlogs/server.log", "$HOME/testlogs/**/*.log"}, manifest.Requested) + require.Len(t, manifest.Files, 2, "server.log should be deduplicated across the exact path and the glob") +} + // nolint:revive // It's a control flag, but this is just a test. func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAgent bool, badValues []string) { t.Helper() @@ -314,6 +390,11 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge defer r.Close() for _, f := range r.File { assertDoesNotContain(t, f, badValues...) + if strings.HasPrefix(f.Name, "agent/workspace_files/files/") { + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "workspace log file should not be empty") + continue + } switch f.Name { case "deployment/buildinfo.json": var v codersdk.BuildInfoResponse @@ -490,6 +571,10 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge continue } require.Contains(t, string(bs), "started up") + case "agent/workspace_files/manifest.json": + var v workspacesdk.BundleFilesManifest + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v.Requested, "workspace log file manifest should include requested paths") case "logs.txt": bs := readBytesFromZip(t, f) require.NotEmpty(t, bs, "logs should not be empty") @@ -517,11 +602,21 @@ func readBytesFromZip(t *testing.T, f *zip.File) []byte { t.Helper() rc, err := f.Open() require.NoError(t, err, "open file from zip") + defer rc.Close() bs, err := io.ReadAll(rc) require.NoError(t, err, "read bytes from zip") return bs } +// readZipEntries reads every entry of the zip at zipPath into memory. +func readZipEntries(t *testing.T, zipPath string) map[string][]byte { + t.Helper() + + data, err := os.ReadFile(zipPath) + require.NoError(t, err, "read zip file") + return testutil.ReadZip(t, data) +} + func assertDoesNotContain(t *testing.T, f *zip.File, vals ...string) { t.Helper() bs := readBytesFromZip(t, f) diff --git a/cli/testdata/coder_support_bundle_--help.golden b/cli/testdata/coder_support_bundle_--help.golden index 0843a43f56..75289d8631 100644 --- a/cli/testdata/coder_support_bundle_--help.golden +++ b/cli/testdata/coder_support_bundle_--help.golden @@ -28,6 +28,13 @@ OPTIONS: Override the URL to your Coder deployment. This may be useful, for example, if you need to troubleshoot a specific Coder replica. + --workspace-file string-array, $CODER_SUPPORT_BUNDLE_WORKSPACE_FILE + File path or glob to collect from inside the remote workspace. + Environment variables are expanded in the workspace; paths must then + be absolute or start with ~/, which resolves against the agent user's + home directory. Files local to the machine running this command are + not collected. Can be specified multiple times. + --workspaces-total-cap int, $CODER_SUPPORT_BUNDLE_WORKSPACES_TOTAL_CAP Maximum number of workspaces to include in the support bundle. Set to 0 or negative value to disable the cap. Defaults to 10. diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 6869f42903..e86ad80192 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -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) diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 524fc6a38a..2647f409c1 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -84,6 +84,21 @@ func (mr *MockAgentConnMockRecorder) AwaitReachable(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AwaitReachable", reflect.TypeOf((*MockAgentConn)(nil).AwaitReachable), ctx) } +// BundleFiles mocks base method. +func (m *MockAgentConn) BundleFiles(ctx context.Context, req workspacesdk.BundleFilesRequest) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BundleFiles", ctx, req) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BundleFiles indicates an expected call of BundleFiles. +func (mr *MockAgentConnMockRecorder) BundleFiles(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BundleFiles", reflect.TypeOf((*MockAgentConn)(nil).BundleFiles), ctx, req) +} + // CallMCPTool mocks base method. func (m *MockAgentConn) CallMCPTool(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) { m.ctrl.T.Helper() diff --git a/docs/reference/cli/support_bundle.md b/docs/reference/cli/support_bundle.md index 9c58131892..5854dafccd 100644 --- a/docs/reference/cli/support_bundle.md +++ b/docs/reference/cli/support_bundle.md @@ -61,6 +61,15 @@ Maximum number of workspaces to include in the support bundle. Set to 0 or negat Template name to include in the support bundle. Use org_name/template_name if template name is reused across multiple organizations. +### --workspace-file + +| | | +|-------------|---------------------------------------------------| +| Type | string-array | +| Environment | $CODER_SUPPORT_BUNDLE_WORKSPACE_FILE | + +File path or glob to collect from inside the remote workspace. Environment variables are expanded in the workspace; paths must then be absolute or start with ~/, which resolves against the agent user's home directory. Files local to the machine running this command are not collected. Can be specified multiple times. + ### --pprof | | | diff --git a/docs/support/support-bundle.md b/docs/support/support-bundle.md index bf9c22d50d..6d79e11c3a 100644 --- a/docs/support/support-bundle.md +++ b/docs/support/support-bundle.md @@ -27,33 +27,36 @@ A brief overview of all files contained in the bundle is provided below: > Detailed descriptions of all the information available in the bundle is > out of scope, as support bundles are primarily intended for internal use. -| Filename | Description | -|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| -| `agent/agent.json` | The agent used to connect to the workspace with environment variables stripped. | -| `agent/agent_magicsock.html` | The contents of the HTTP debug endpoint of the agent's Tailscale Wireguard connection. | -| `agent/client_magicsock.html` | The contents of the HTTP debug endpoint of the client's Tailscale Wireguard connection. | -| `agent/listening_ports.json` | The listening ports detected by the selected agent running in the workspace. | -| `agent/logs.txt` | Active agent log plus rotated agent logs modified in the last 24 hours, capped at 100 MiB. | -| `agent/manifest.json` | The manifest of the selected agent with environment variables stripped. | -| `agent/startup_logs.txt` | Startup logs of the workspace agent. | -| `agent/prometheus.txt` | The contents of the agent's Prometheus endpoint. | -| `cli_logs.txt` | Logs from running the `coder support bundle` command. | -| `deployment/buildinfo.json` | Coder version and build information. | -| `deployment/config.json` | Deployment [configuration](../reference/api/general.md#get-deployment-config), with secret values removed. *Requires Owner role.* | -| `deployment/experiments.json` | Any [experiments](../reference/cli/server.md#--experiments) currently enabled for the deployment. | -| `deployment/health.json` | A snapshot of the [health status](../admin/monitoring/health-check.md) of the deployment. *Requires Owner role.* | -| `logs.txt` | Logs from the `codersdk.Client` used to generate the bundle. | -| `network/connection_info.json` | Information used by workspace agents used to connect to Coder (DERP map etc.) | -| `network/coordinator_debug.html` | Peers currently connected to each Coder instance and the tunnels established between peers. *Requires Owner role.* | -| `network/netcheck.json` | Results of running `coder netcheck` locally. | -| `network/tailnet_debug.html` | Tailnet coordinators, their heartbeat ages, connected peers, and tunnels. *Requires Owner role.* | -| `workspace/build_logs.txt` | Build logs of the selected workspace. | -| `workspace/workspace.json` | Details of the selected workspace. | -| `workspace/parameters.json` | Build parameters of the selected workspace. | -| `workspace/template.json` | The template currently in use by the selected workspace. | -| `workspace/template_file.zip` | The source code of the template currently in use by the selected workspace. | -| `workspace/template_version.json` | The template version currently in use by the selected workspace. | -| `vscode-logs/` | Only present when generated from the VS Code Coder Remote extension. Includes logs, redacted settings, and local telemetry files. | +| Filename | Description | +|-----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent/agent.json` | The agent used to connect to the workspace with environment variables stripped. | +| `agent/agent_magicsock.html` | The contents of the HTTP debug endpoint of the agent's Tailscale Wireguard connection. | +| `agent/client_magicsock.html` | The contents of the HTTP debug endpoint of the client's Tailscale Wireguard connection. | +| `agent/listening_ports.json` | The listening ports detected by the selected agent running in the workspace. | +| `agent/logs.txt` | Active agent log plus rotated agent logs modified in the last 24 hours, capped at 100 MiB. | +| `agent/workspace_files/collection_errors.txt` | Workspace file entries dropped while assembling the bundle, such as entries exceeding the size budget. Only present when entries were dropped. | +| `agent/workspace_files/files/` | Files collected from inside the remote workspace with `--workspace-file`. Only present when workspace paths are requested. | +| `agent/workspace_files/manifest.json` | Describes the remote workspace file collection: requested patterns, collected files, per-path errors, truncation, and applied limits. Only present when workspace paths are requested. | +| `agent/manifest.json` | The manifest of the selected agent with environment variables stripped. | +| `agent/startup_logs.txt` | Startup logs of the workspace agent. | +| `agent/prometheus.txt` | The contents of the agent's Prometheus endpoint. | +| `cli_logs.txt` | Logs from running the `coder support bundle` command. | +| `deployment/buildinfo.json` | Coder version and build information. | +| `deployment/config.json` | Deployment [configuration](../reference/api/general.md#get-deployment-config), with secret values removed. *Requires Owner role.* | +| `deployment/experiments.json` | Any [experiments](../reference/cli/server.md#--experiments) currently enabled for the deployment. | +| `deployment/health.json` | A snapshot of the [health status](../admin/monitoring/health-check.md) of the deployment. *Requires Owner role.* | +| `logs.txt` | Logs from the `codersdk.Client` used to generate the bundle. | +| `network/connection_info.json` | Information used by workspace agents used to connect to Coder (DERP map etc.) | +| `network/coordinator_debug.html` | Peers currently connected to each Coder instance and the tunnels established between peers. *Requires Owner role.* | +| `network/netcheck.json` | Results of running `coder netcheck` locally. | +| `network/tailnet_debug.html` | Tailnet coordinators, their heartbeat ages, connected peers, and tunnels. *Requires Owner role.* | +| `workspace/build_logs.txt` | Build logs of the selected workspace. | +| `workspace/workspace.json` | Details of the selected workspace. | +| `workspace/parameters.json` | Build parameters of the selected workspace. | +| `workspace/template.json` | The template currently in use by the selected workspace. | +| `workspace/template_file.zip` | The source code of the template currently in use by the selected workspace. | +| `workspace/template_version.json` | The template version currently in use by the selected workspace. | +| `vscode-logs/` | Only present when generated from the VS Code Coder Remote extension. Includes logs, redacted settings, and local telemetry files. | ## How do I generate a Support Bundle? @@ -88,6 +91,32 @@ A brief overview of all files contained in the bundle is provided below: > While support bundles can be generated without a running workspace, it is > recommended to specify one to maximize troubleshooting information. + To collect workspace-side files such as editor or service logs, add one + `--workspace-file` flag for each path or glob. This is explicit + opt-in. The CLI sends each value to the workspace agent, so quote globs to + prevent your local shell from expanding them: + + ```sh + coder support bundle my-workspace \ + --workspace-file '$HOME/.vscode-server/data/logs/**/*.log' \ + --workspace-file '$HOME/.local/share/code-server/coder-logs/**/*.log' + ``` + + Workspace paths and globs are evaluated by the workspace agent. + Environment variables such as `$HOME` expand in the workspace, and `~/` + resolves against the agent user's home directory; any absolute path in + the workspace can be requested. Symlinks are followed for directly + requested paths, but not during glob traversal. Collection is limited to + 10000 files and 100 MiB in total; files larger than 10 MiB are truncated + to their last 10 MiB and marked as truncated in the manifest. Collected + files are stored under `agent/workspace_files/files/`, and collection + metadata is stored in `agent/workspace_files/manifest.json`. + + > [!WARNING] + > Workspace files can contain tokens, credentials, source code, or other + > sensitive data. Extract and review `agent/workspace_files/` before sharing + > the bundle. + 5. (Recommended) Extract the support bundle and review its contents, redacting any information you deem necessary. diff --git a/go.mod b/go.mod index b0ed50c2e7..374c884580 100644 --- a/go.mod +++ b/go.mod @@ -322,7 +322,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/golibsass v1.2.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/chromedp/sysutil v1.1.0 // indirect diff --git a/support/support.go b/support/support.go index de30ee8554..40be6ddf62 100644 --- a/support/support.go +++ b/support/support.go @@ -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 }) diff --git a/support/support_internal_test.go b/support/support_internal_test.go new file mode 100644 index 0000000000..c1a90b1e74 --- /dev/null +++ b/support/support_internal_test.go @@ -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) +} diff --git a/support/support_test.go b/support/support_test.go index e2c628b5c0..35bfa55673 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -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() diff --git a/testutil/archive.go b/testutil/archive.go index 88b7c428cd..63617a7a8e 100644 --- a/testutil/archive.go +++ b/testutil/archive.go @@ -2,7 +2,10 @@ package testutil import ( "archive/tar" + "archive/zip" "bytes" + "errors" + "io" "path/filepath" "testing" @@ -59,3 +62,39 @@ func CreateZip(t testing.TB, files map[string]string) []byte { require.NoError(t, err) return za } + +// Reads every entry of the in-memory tar into a map keyed by entry name. +func ReadTar(t testing.TB, data []byte) map[string][]byte { + t.Helper() + + entries := make(map[string][]byte) + tr := tar.NewReader(bytes.NewReader(data)) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return entries + } + require.NoError(t, err) + content, err := io.ReadAll(tr) + require.NoError(t, err) + entries[hdr.Name] = content + } +} + +// Reads every entry of the in-memory zip into a map keyed by entry name. +func ReadZip(t testing.TB, data []byte) map[string][]byte { + t.Helper() + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + entries := make(map[string][]byte, len(zr.File)) + for _, file := range zr.File { + rc, err := file.Open() + require.NoError(t, err) + content, err := io.ReadAll(rc) + _ = rc.Close() + require.NoError(t, err) + entries[file.Name] = content + } + return entries +}