Files
coder/cli/support_test.go
T
Ehab Younes 3362b5ae7e fix: infer workspace from env in coder support bundle (#24617)
When running `coder support bundle` inside a workspace without arguments, the command now infers the workspace and agent from the `CODER_WORKSPACE_NAME`, `CODER_WORKSPACE_OWNER_NAME`, and `CODER_WORKSPACE_AGENT_NAME` environment variables set by the workspace agent.

Previously, running without arguments inside a workspace produced an incomplete bundle with no workspace info, agent logs, or connection diagnostics, despite the environment having all the information needed to resolve the current workspace.

Also updates the usage string from `<workspace>` to `[<workspace>]` to reflect that the argument has always been optional.

Closes #24615
2026-04-22 17:21:28 +03:00

541 lines
18 KiB
Go

package cli_test
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/ipn/ipnstate"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/healthcheck"
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
"github.com/coder/coder/v2/coderd/healthcheck/health"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)
func TestSupportBundle(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("for some reason, windows fails to remove tempdirs sometimes")
}
// Support bundle tests can share a single coderdtest instance.
var dc codersdk.DeploymentConfig
dc.Values = coderdtest.DeploymentValues(t)
dc.Values.Prometheus.Enable = true
secretValue := uuid.NewString()
seedSecretDeploymentOptions(t, &dc, secretValue)
// Use a mock healthcheck function to avoid flaky DERP health
// checks in CI. The DERP checker performs real network operations
// (portmapper gateway probing, STUN) that can hang for 60s+ on
// macOS CI runners. Since this test validates support bundle
// generation, not healthcheck correctness, a canned report is
// sufficient.
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)
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Set up test fixtures
setupCtx := testutil.Context(t, testutil.WaitLong)
workspaceWithAgent := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, owner.UserID, func(agents []*proto.Agent) []*proto.Agent {
// This should not show up in the bundle output
agents[0].Env["SECRET_VALUE"] = secretValue
return agents
})
workspaceWithoutAgent := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, owner.UserID, nil)
memberWorkspace := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, member.ID, nil)
t.Run("WorkspaceWithAgent", func(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
logPath := filepath.Join(tempDir, "coder-agent.log")
require.NoError(t, os.WriteFile(logPath, []byte("hello from the agent"), 0o600))
agt := agenttest.New(t, client.URL, workspaceWithAgent.AgentToken, func(o *agent.Options) {
o.LogDir = tempDir
})
defer agt.Close()
coderdtest.NewWorkspaceAgentWaiter(t, client, workspaceWithAgent.Workspace.ID).Wait()
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
inv, root := clitest.New(t, "support", "bundle", workspaceWithAgent.Workspace.Name, "--output-file", path, "--yes")
//nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.NoError(t, err)
assertBundleContents(t, path, true, true, []string{secretValue})
})
t.Run("NoWorkspace", func(t *testing.T) {
t.Parallel()
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
inv, root := clitest.New(t, "support", "bundle", "--output-file", path, "--yes")
//nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.NoError(t, err)
assertBundleContents(t, path, false, false, []string{secretValue})
})
t.Run("InferWorkspaceFromEnvByID", func(t *testing.T) {
t.Parallel()
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
// No workspace arg, but set env vars as if inside a workspace.
inv, root := clitest.New(t, "support", "bundle", "--output-file", path, "--yes")
inv.Environ.Set("CODER", "true")
inv.Environ.Set("CODER_WORKSPACE_ID", workspaceWithoutAgent.Workspace.ID.String())
inv.Environ.Set("CODER_WORKSPACE_AGENT_NAME", "dev")
//nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.NoError(t, err)
// The workspace should be resolved, but there is no running agent.
assertBundleContents(t, path, true, false, []string{secretValue})
})
t.Run("InferWorkspaceFromEnvByName", func(t *testing.T) {
t.Parallel()
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
// No workspace arg and no CODER_WORKSPACE_ID; fall back to
// owner/name resolution for older agents.
inv, root := clitest.New(t, "support", "bundle", "--output-file", path, "--yes")
inv.Environ.Set("CODER", "true")
inv.Environ.Set("CODER_WORKSPACE_NAME", workspaceWithoutAgent.Workspace.Name)
inv.Environ.Set("CODER_WORKSPACE_OWNER_NAME", coderdtest.FirstUserParams.Username)
inv.Environ.Set("CODER_WORKSPACE_AGENT_NAME", "dev")
//nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.NoError(t, err)
assertBundleContents(t, path, true, false, []string{secretValue})
})
t.Run("NoAgent", func(t *testing.T) {
t.Parallel()
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
inv, root := clitest.New(t, "support", "bundle", workspaceWithoutAgent.Workspace.Name, "--output-file", path, "--yes")
//nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.NoError(t, err)
assertBundleContents(t, path, true, false, []string{secretValue})
})
t.Run("MemberCanGenerateBundle", func(t *testing.T) {
t.Parallel()
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--output-file", path, "--yes")
clitest.SetupConfig(t, memberClient, root)
err := inv.Run()
require.NoError(t, err)
r, err := zip.OpenReader(path)
require.NoError(t, err, "open zip file")
defer r.Close()
fileNames := make(map[string]struct{}, len(r.File))
for _, f := range r.File {
fileNames[f.Name] = struct{}{}
}
// These should always be present in the zip structure, even if
// the content is null/empty for non-admin users.
for _, name := range []string{
"deployment/buildinfo.json",
"deployment/config.json",
"workspace/workspace.json",
"logs.txt",
"cli_logs.txt",
"network/netcheck.json",
"network/interfaces.json",
} {
require.Contains(t, fileNames, name)
}
})
// This ensures that the CLI does not panic when trying to generate a support bundle
// against a fake server that returns an empty response for all requests. This essentially
// ensures that (almost) all of the support bundle generating code paths get a zero value.
t.Run("DontPanic", func(t *testing.T) {
t.Parallel()
for _, code := range []int{
http.StatusOK,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
http.StatusInternalServerError,
} {
t.Run(http.StatusText(code), func(t *testing.T) {
t.Parallel()
// Start up a fake server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("received request: %s %s", r.Method, r.URL)
switch r.URL.Path {
case "/api/v2/users/me":
resp := codersdk.User{}
w.WriteHeader(http.StatusOK)
assert.NoError(t, json.NewEncoder(w).Encode(resp))
case "/api/v2/authcheck":
// Fake auth check
resp := codersdk.AuthorizationResponse{
"Read DeploymentValues": true,
}
w.WriteHeader(http.StatusOK)
assert.NoError(t, json.NewEncoder(w).Encode(resp))
default:
// Simply return a blank response for everything else.
w.WriteHeader(code)
}
}))
defer srv.Close()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.New(u)
d := t.TempDir()
path := filepath.Join(d, "bundle.zip")
inv, root := clitest.New(t, "support", "bundle", "--url-override", srv.URL, "--output-file", path, "--yes")
clitest.SetupConfig(t, client, root)
err = inv.Run()
require.NoError(t, err)
})
}
})
}
// 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()
r, err := zip.OpenReader(path)
require.NoError(t, err, "open zip file")
defer r.Close()
for _, f := range r.File {
assertDoesNotContain(t, f, badValues...)
switch f.Name {
case "deployment/buildinfo.json":
var v codersdk.BuildInfoResponse
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "deployment build info should not be empty")
case "deployment/config.json":
var v codersdk.DeploymentConfig
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "deployment config should not be empty")
case "deployment/entitlements.json":
var v codersdk.Entitlements
decodeJSONFromZip(t, f, &v)
require.NotNil(t, v, "entitlements should not be nil")
case "deployment/experiments.json":
var v codersdk.Experiments
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, f, v, "experiments should not be empty")
case "deployment/health.json":
var v healthsdk.HealthcheckReport
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "health report should not be empty")
case "deployment/health_settings.json":
var v healthsdk.HealthSettings
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "health settings should not be empty")
case "deployment/stats.json":
var v codersdk.DeploymentStats
decodeJSONFromZip(t, f, &v)
require.NotNil(t, v, "deployment stats should not be nil")
case "deployment/workspaces.json":
var v codersdk.Workspace
decodeJSONFromZip(t, f, &v)
require.NotNil(t, v, "deployment workspaces should not be nil")
case "deployment/prometheus.txt":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "prometheus metrics should not be empty")
require.Contains(t, string(bs), "go_goroutines", "prometheus metrics should contain go runtime metrics")
case "network/connection_info.json":
var v workspacesdk.AgentConnectionInfo
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "agent connection info should not be empty")
case "network/coordinator_debug.html":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "coordinator debug should not be empty")
case "network/tailnet_debug.html":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "tailnet debug should not be empty")
case "network/netcheck.json":
var v derphealth.Report
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "netcheck should not be empty")
case "network/interfaces.json":
var v healthsdk.InterfacesReport
decodeJSONFromZip(t, f, &v)
require.NotEmpty(t, v, "interfaces should not be empty")
case "workspace/workspace.json":
var v codersdk.Workspace
decodeJSONFromZip(t, f, &v)
if !wantWorkspace {
require.Empty(t, v, "expected workspace to be empty")
continue
}
require.NotEmpty(t, v, "workspace should not be empty")
case "workspace/build_logs.txt":
bs := readBytesFromZip(t, f)
if !wantWorkspace {
require.Empty(t, bs, "expected workspace build logs to be empty")
continue
}
require.Contains(t, string(bs), "provision done")
case "workspace/template.json":
var v codersdk.Template
decodeJSONFromZip(t, f, &v)
if !wantWorkspace {
require.Empty(t, v, "expected workspace template to be empty")
continue
}
require.NotEmpty(t, v, "workspace template should not be empty")
case "workspace/template_version.json":
var v codersdk.TemplateVersion
decodeJSONFromZip(t, f, &v)
if !wantWorkspace {
require.Empty(t, v, "expected workspace template version to be empty")
continue
}
require.NotEmpty(t, v, "workspace template version should not be empty")
case "workspace/parameters.json":
var v []codersdk.WorkspaceBuildParameter
decodeJSONFromZip(t, f, &v)
if !wantWorkspace {
require.Empty(t, v, "expected workspace parameters to be empty")
continue
}
require.NotNil(t, v, "workspace parameters should not be nil")
case "workspace/template_file.zip":
bs := readBytesFromZip(t, f)
if !wantWorkspace {
require.Empty(t, bs, "expected template file to be empty")
continue
}
require.NotNil(t, bs, "template file should not be nil")
case "agent/agent.json":
var v codersdk.WorkspaceAgent
decodeJSONFromZip(t, f, &v)
if !wantAgent {
require.Empty(t, v, "expected agent to be empty")
continue
}
require.NotEmpty(t, v, "agent should not be empty")
case "agent/listening_ports.json":
var v codersdk.WorkspaceAgentListeningPortsResponse
decodeJSONFromZip(t, f, &v)
if !wantAgent {
require.Empty(t, v, "expected agent listening ports to be empty")
continue
}
require.NotEmpty(t, v, "agent listening ports should not be empty")
case "agent/logs.txt":
bs := readBytesFromZip(t, f)
if !wantAgent {
require.Empty(t, bs, "expected agent logs to be empty")
continue
}
require.NotEmpty(t, bs, "logs should not be empty")
case "agent/agent_magicsock.html":
bs := readBytesFromZip(t, f)
if !wantAgent {
require.Empty(t, bs, "expected agent magicsock to be empty")
continue
}
require.NotEmpty(t, bs, "agent magicsock should not be empty")
case "agent/client_magicsock.html":
bs := readBytesFromZip(t, f)
if !wantAgent {
require.Empty(t, bs, "expected client magicsock to be empty")
continue
}
require.NotEmpty(t, bs, "client magicsock should not be empty")
case "agent/manifest.json":
var v agentsdk.Manifest
decodeJSONFromZip(t, f, &v)
if !wantAgent {
require.Empty(t, v, "expected agent manifest to be empty")
continue
}
require.NotEmpty(t, v, "agent manifest should not be empty")
case "agent/peer_diagnostics.json":
var v *tailnet.PeerDiagnostics
decodeJSONFromZip(t, f, &v)
if !wantAgent {
require.Empty(t, v, "expected peer diagnostics to be empty")
continue
}
require.NotEmpty(t, v, "peer diagnostics should not be empty")
case "agent/ping_result.json":
var v *ipnstate.PingResult
decodeJSONFromZip(t, f, &v)
if !wantAgent {
require.Empty(t, v, "expected ping result to be empty")
continue
}
require.NotEmpty(t, v, "ping result should not be empty")
case "agent/prometheus.txt":
bs := readBytesFromZip(t, f)
if !wantAgent {
require.Empty(t, bs, "expected agent prometheus metrics to be empty")
continue
}
require.NotEmpty(t, bs, "agent prometheus metrics should not be empty")
case "agent/startup_logs.txt":
bs := readBytesFromZip(t, f)
if !wantAgent {
require.Empty(t, bs, "expected agent startup logs to be empty")
continue
}
require.Contains(t, string(bs), "started up")
case "logs.txt":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "logs should not be empty")
case "cli_logs.txt":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "CLI logs should not be empty")
case "license-status.txt":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "license status should not be empty")
default:
require.Failf(t, "unexpected file in bundle", f.Name)
}
}
}
func decodeJSONFromZip(t *testing.T, f *zip.File, dest any) {
t.Helper()
rc, err := f.Open()
require.NoError(t, err, "open file from zip")
defer rc.Close()
require.NoError(t, json.NewDecoder(rc).Decode(&dest))
}
func readBytesFromZip(t *testing.T, f *zip.File) []byte {
t.Helper()
rc, err := f.Open()
require.NoError(t, err, "open file from zip")
bs, err := io.ReadAll(rc)
require.NoError(t, err, "read bytes from zip")
return bs
}
func assertDoesNotContain(t *testing.T, f *zip.File, vals ...string) {
t.Helper()
bs := readBytesFromZip(t, f)
for _, val := range vals {
if bytes.Contains(bs, []byte(val)) {
t.Fatalf("file %q should not contain value %q", f.Name, val)
}
}
}
func seedSecretDeploymentOptions(t *testing.T, dc *codersdk.DeploymentConfig, secretValue string) {
t.Helper()
if dc == nil {
dc = &codersdk.DeploymentConfig{}
}
for _, opt := range dc.Options {
if codersdk.IsSecretDeploymentOption(opt) {
opt.Value.Set(secretValue)
}
}
}
func setupSupportBundleTestFixture(
ctx context.Context,
t testing.TB,
db database.Store,
orgID, ownerID uuid.UUID,
withAgent func([]*proto.Agent) []*proto.Agent,
) dbfake.WorkspaceResponse {
t.Helper()
// nolint: gocritic // Used for seeding test data only.
ctx = dbauthz.AsSystemRestricted(ctx)
b := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: orgID,
OwnerID: ownerID,
})
if withAgent != nil {
b = b.WithAgent(withAgent)
}
r := b.Do()
_, err := db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{
JobID: r.Build.JobID,
CreatedAt: []time.Time{dbtime.Now()},
Source: []database.LogSource{database.LogSourceProvisionerDaemon},
Level: []database.LogLevel{database.LogLevelInfo},
Stage: []string{"provision"},
Output: []string{"done"},
})
require.NoError(t, err)
if withAgent != nil {
res, err := db.GetWorkspaceResourcesByJobID(ctx, r.Build.JobID)
require.NoError(t, err)
var resIDs []uuid.UUID
for _, res := range res {
resIDs = append(resIDs, res.ID)
}
agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, resIDs)
require.NoError(t, err)
for _, agt := range agents {
_, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{
AgentID: agt.ID,
CreatedAt: dbtime.Now(),
Output: []string{"started up"},
Level: []database.LogLevel{database.LogLevelInfo},
LogSourceID: r.Build.JobID,
OutputLength: 10,
})
require.NoError(t, err)
}
}
return r
}