mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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
125 lines
3.7 KiB
Go
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()
|
|
}
|