Files
coder/cli/support_internal_test.go
T
Ehab Younes 35ade9e3d2 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
2026-07-16 13:00:32 +03:00

125 lines
3.7 KiB
Go

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()
}