package cli_test import ( "archive/zip" "bytes" "encoding/json" "io" "os" "path/filepath" "runtime" "testing" "time" "tailscale.com/ipn/ipnstate" "github.com/google/uuid" "github.com/stretchr/testify/require" "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/dbfake" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/healthcheck/derphealth" "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") } t.Run("Workspace", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) var dc codersdk.DeploymentConfig secretValue := uuid.NewString() seedSecretDeploymentOptions(t, &dc, secretValue) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ DeploymentValues: dc.Values, }) owner := coderdtest.CreateFirstUser(t, client) r := dbfake.WorkspaceBuild(t, db, database.Workspace{ OrganizationID: owner.OrganizationID, OwnerID: owner.UserID, }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { // This should not show up in the bundle output agents[0].Env["SECRET_VALUE"] = secretValue return agents }).Do() ws, err := client.Workspace(ctx, r.Workspace.ID) require.NoError(t, err) 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, r.AgentToken, func(o *agent.Options) { o.LogDir = tempDir }) defer agt.Close() coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() // Insert a provisioner job log _, 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) // Insert an agent log _, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{ AgentID: ws.LatestBuild.Resources[0].Agents[0].ID, CreatedAt: dbtime.Now(), Output: []string{"started up"}, Level: []database.LogLevel{database.LogLevelInfo}, LogSourceID: r.Build.JobID, OutputLength: 10, }) require.NoError(t, err) d := t.TempDir() path := filepath.Join(d, "bundle.zip") inv, root := clitest.New(t, "support", "bundle", r.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() var dc codersdk.DeploymentConfig secretValue := uuid.NewString() seedSecretDeploymentOptions(t, &dc, secretValue) client := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: dc.Values, }) _ = coderdtest.CreateFirstUser(t, client) 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("NoAgent", func(t *testing.T) { t.Parallel() var dc codersdk.DeploymentConfig secretValue := uuid.NewString() seedSecretDeploymentOptions(t, &dc, secretValue) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ DeploymentValues: dc.Values, }) admin := coderdtest.CreateFirstUser(t, client) r := dbfake.WorkspaceBuild(t, db, database.Workspace{ OrganizationID: admin.OrganizationID, OwnerID: admin.UserID, }).Do() // without agent! d := t.TempDir() path := filepath.Join(d, "bundle.zip") inv, root := clitest.New(t, "support", "bundle", r.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("NoPrivilege", func(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) user := coderdtest.CreateFirstUser(t, client) memberClient, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) r := dbfake.WorkspaceBuild(t, db, database.Workspace{ OrganizationID: user.OrganizationID, OwnerID: member.ID, }).WithAgent().Do() inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name, "--yes") clitest.SetupConfig(t, memberClient, root) err := inv.Run() require.ErrorContains(t, err, "failed authorization check") }) } // 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/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 "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 "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 || !wantAgent { 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") 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) } } }