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
+95
View File
@@ -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)