diff --git a/codersdk/health.go b/codersdk/health.go index 266d05c2e20c9..faa252464eb9e 100644 --- a/codersdk/health.go +++ b/codersdk/health.go @@ -44,6 +44,19 @@ type UpdateHealthSettings struct { DismissedHealthchecks []HealthSection `json:"dismissed_healthchecks"` } +func (c *Client) DebugHealth(ctx context.Context) (HealthcheckReport, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health", nil) + if err != nil { + return HealthcheckReport{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return HealthcheckReport{}, ReadBodyAsError(res) + } + var rpt HealthcheckReport + return rpt, json.NewDecoder(res.Body).Decode(&rpt) +} + func (c *Client) HealthSettings(ctx context.Context) (HealthSettings, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health/settings", nil) if err != nil { diff --git a/support/support.go b/support/support.go new file mode 100644 index 0000000000000..b8eda1f1b98e4 --- /dev/null +++ b/support/support.go @@ -0,0 +1,227 @@ +package support + +import ( + "context" + "io" + "net/http" + "strings" + + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" +) + +// Bundle is a set of information discovered about a deployment. +// Even though we do attempt to sanitize data, it may still contain +// sensitive information and should thus be treated as secret. +type Bundle struct { + Deployment Deployment `json:"deployment"` + Network Network `json:"network"` + Workspace Workspace `json:"workspace"` + Logs []string `json:"logs"` +} + +type Deployment struct { + BuildInfo *codersdk.BuildInfoResponse `json:"build"` + Config *codersdk.DeploymentConfig `json:"config"` + Experiments codersdk.Experiments `json:"experiments"` + HealthReport *codersdk.HealthcheckReport `json:"health_report"` +} + +type Network struct { + CoordinatorDebug string `json:"coordinator_debug"` + TailnetDebug string `json:"tailnet_debug"` + NetcheckLocal *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck_local"` + NetcheckRemote *codersdk.WorkspaceAgentConnectionInfo `json:"netcheck_remote"` +} + +type Workspace struct { + Workspace codersdk.Workspace `json:"workspace"` + BuildLogs []codersdk.ProvisionerJobLog `json:"build_logs"` + Agent codersdk.WorkspaceAgent `json:"agent"` + AgentStartupLogs []codersdk.WorkspaceAgentLog `json:"startup_logs"` +} + +// Deps is a set of dependencies for discovering information +type Deps struct { + // Source from which to obtain information. + Client *codersdk.Client + // Log is where to log any informational or warning messages. + Log slog.Logger + // WorkspaceID is the optional workspace against which to run connection tests. + WorkspaceID uuid.UUID + // AgentID is the optional agent ID against which to run connection tests. + // Defaults to the first agent of the workspace, if not specified. + AgentID uuid.UUID +} + +func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) Deployment { + var d Deployment + + bi, err := client.BuildInfo(ctx) + if err != nil { + log.Error(ctx, "fetch build info", slog.Error(err)) + } else { + d.BuildInfo = &bi + } + + dc, err := client.DeploymentConfig(ctx) + if err != nil { + log.Error(ctx, "fetch deployment config", slog.Error(err)) + } else { + d.Config = dc + } + + hr, err := client.DebugHealth(ctx) + if err != nil { + log.Error(ctx, "fetch health report", slog.Error(err)) + } else { + d.HealthReport = &hr + } + + exp, err := client.Experiments(ctx) + if err != nil { + log.Error(ctx, "fetch experiments", slog.Error(err)) + } else { + d.Experiments = exp + } + + return d +} + +func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID) Network { + var n Network + + coordResp, err := client.Request(ctx, http.MethodGet, "/api/v2/debug/coordinator", nil) + if err != nil { + log.Error(ctx, "fetch coordinator debug page", slog.Error(err)) + } else { + defer coordResp.Body.Close() + bs, err := io.ReadAll(coordResp.Body) + if err != nil { + log.Error(ctx, "read coordinator debug page", slog.Error(err)) + } else { + n.CoordinatorDebug = string(bs) + } + } + + tailResp, err := client.Request(ctx, http.MethodGet, "/api/v2/debug/tailnet", nil) + if err != nil { + log.Error(ctx, "fetch tailnet debug page", slog.Error(err)) + } else { + defer tailResp.Body.Close() + bs, err := io.ReadAll(tailResp.Body) + if err != nil { + log.Error(ctx, "read tailnet debug page", slog.Error(err)) + } else { + n.TailnetDebug = string(bs) + } + } + + if agentID != uuid.Nil { + connInfo, err := client.WorkspaceAgentConnectionInfo(ctx, agentID) + if err != nil { + log.Error(ctx, "fetch agent conn info", slog.Error(err), slog.F("agent_id", agentID.String())) + } else { + n.NetcheckLocal = &connInfo + } + } else { + log.Warn(ctx, "agent id required for agent connection info") + } + + return n +} + +func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, workspaceID, agentID uuid.UUID) Workspace { + var w Workspace + + if workspaceID == uuid.Nil { + log.Error(ctx, "no workspace id specified") + return w + } + + if agentID == uuid.Nil { + log.Error(ctx, "no agent id specified") + } + + ws, err := client.Workspace(ctx, workspaceID) + if err != nil { + log.Error(ctx, "fetch workspace", slog.Error(err), slog.F("workspace_id", workspaceID)) + return w + } + + w.Workspace = ws + + buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0) + if err != nil { + log.Error(ctx, "fetch provisioner job logs", slog.Error(err), slog.F("job_id", ws.LatestBuild.Job.ID.String())) + } else { + defer closer.Close() + for log := range buildLogCh { + w.BuildLogs = append(w.BuildLogs, log) + } + } + + if len(w.Workspace.LatestBuild.Resources) == 0 { + log.Warn(ctx, "workspace build has no resources") + return w + } + + agentLogCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agentID, 0, false) + if err != nil { + log.Error(ctx, "fetch agent startup logs", slog.Error(err), slog.F("agent_id", agentID.String())) + } else { + defer closer.Close() + for logChunk := range agentLogCh { + w.AgentStartupLogs = append(w.AgentStartupLogs, logChunk...) + } + } + + return w +} + +// Run generates a support bundle with the given dependencies. +func Run(ctx context.Context, d *Deps) (*Bundle, error) { + var b Bundle + if d.Client == nil { + return nil, xerrors.Errorf("developer error: missing client!") + } + + authChecks := map[string]codersdk.AuthorizationCheck{ + "Read DeploymentValues": { + Object: codersdk.AuthorizationObject{ + ResourceType: codersdk.ResourceDeploymentValues, + }, + Action: string(rbac.ActionRead), + }, + } + + // Ensure we capture logs from the client. + var logw strings.Builder + d.Log.AppendSinks(sloghuman.Sink(&logw)) + d.Client.SetLogger(d.Log) + defer func() { + b.Logs = strings.Split(logw.String(), "\n") + }() + + authResp, err := d.Client.AuthCheck(ctx, codersdk.AuthorizationRequest{Checks: authChecks}) + if err != nil { + return &b, xerrors.Errorf("check authorization: %w", err) + } + for k, v := range authResp { + if !v { + return &b, xerrors.Errorf("failed authorization check: cannot %s", k) + } + } + + b.Deployment = DeploymentInfo(ctx, d.Client, d.Log) + b.Workspace = WorkspaceInfo(ctx, d.Client, d.Log, d.WorkspaceID, d.AgentID) + b.Network = NetworkInfo(ctx, d.Client, d.Log, d.AgentID) + + return &b, nil +} diff --git a/support/support_test.go b/support/support_test.go new file mode 100644 index 0000000000000..b1132dbdeb45f --- /dev/null +++ b/support/support_test.go @@ -0,0 +1,169 @@ +package support_test + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "cdr.dev/slog/sloggers/slogtest" + "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/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/support" + "github.com/coder/coder/v2/testutil" +) + +func TestRun(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + cfg := coderdtest.DeploymentValues(t) + cfg.Experiments = []string{"foo"} + ctx := testutil.Context(t, testutil.WaitShort) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: cfg, + Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))), + }) + admin := coderdtest.CreateFirstUser(t, client) + ws, agt := setupWorkspaceAndAgent(ctx, t, client, db, admin) + + bun, err := support.Run(ctx, &support.Deps{ + Client: client, + Log: slogtest.Make(t, nil).Named("bundle").Leveled(slog.LevelDebug), + WorkspaceID: ws.ID, + AgentID: agt.ID, + }) + require.NoError(t, err) + require.NotEmpty(t, bun) + require.NotEmpty(t, bun.Deployment.BuildInfo) + require.NotEmpty(t, bun.Deployment.Config) + require.NotEmpty(t, bun.Deployment.Config.Options) + assertSanitizedDeploymentConfig(t, bun.Deployment.Config) + require.NotEmpty(t, bun.Deployment.HealthReport) + require.NotEmpty(t, bun.Deployment.Experiments) + require.NotEmpty(t, bun.Network.CoordinatorDebug) + require.NotEmpty(t, bun.Network.TailnetDebug) + require.NotNil(t, bun.Network.NetcheckLocal) + require.NotNil(t, bun.Workspace.Workspace) + require.NotEmpty(t, bun.Workspace.BuildLogs) + require.NotNil(t, bun.Workspace.Agent) + require.NotEmpty(t, bun.Workspace.AgentStartupLogs) + require.NotEmpty(t, bun.Logs) + }) + + t.Run("OK_NoAgent", func(t *testing.T) { + t.Parallel() + cfg := coderdtest.DeploymentValues(t) + cfg.Experiments = []string{"foo"} + ctx := testutil.Context(t, testutil.WaitShort) + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: cfg, + Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))), + }) + _ = coderdtest.CreateFirstUser(t, client) + bun, err := support.Run(ctx, &support.Deps{ + Client: client, + Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + require.NotEmpty(t, bun) + require.NotEmpty(t, bun.Deployment.BuildInfo) + require.NotEmpty(t, bun.Deployment.Config) + require.NotEmpty(t, bun.Deployment.Config.Options) + assertSanitizedDeploymentConfig(t, bun.Deployment.Config) + require.NotEmpty(t, bun.Deployment.HealthReport) + require.NotEmpty(t, bun.Deployment.Experiments) + require.NotEmpty(t, bun.Network.CoordinatorDebug) + require.NotEmpty(t, bun.Network.TailnetDebug) + require.NotNil(t, bun.Workspace) + require.NotEmpty(t, bun.Logs) + }) + + t.Run("NoAuth", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + client := coderdtest.New(t, &coderdtest.Options{ + Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))), + }) + bun, err := support.Run(ctx, &support.Deps{ + Client: client, + Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug), + }) + var sdkErr *codersdk.Error + require.NotNil(t, bun) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) + require.NotEmpty(t, bun) + require.NotEmpty(t, bun.Logs) + }) + + t.Run("MissingPrivilege", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + client := coderdtest.New(t, &coderdtest.Options{ + Logger: ptr.Ref(slog.Make(sloghuman.Sink(io.Discard))), + }) + admin := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + bun, err := support.Run(ctx, &support.Deps{ + Client: memberClient, + Log: slogtest.Make(t, nil).Named("bundle").Leveled(slog.LevelDebug), + }) + require.ErrorContains(t, err, "failed authorization check") + require.NotEmpty(t, bun) + require.NotEmpty(t, bun.Logs) + }) +} + +func assertSanitizedDeploymentConfig(t *testing.T, dc *codersdk.DeploymentConfig) { + t.Helper() + for _, opt := range dc.Options { + if opt.Annotations.IsSet("secret") { + assert.Empty(t, opt.Value.String()) + } + } +} + +func setupWorkspaceAndAgent(ctx context.Context, t *testing.T, client *codersdk.Client, db database.Store, user codersdk.CreateFirstUserResponse) (codersdk.Workspace, codersdk.WorkspaceAgent) { + wbr := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + ws, err := client.Workspace(ctx, wbr.Workspace.ID) + require.NoError(t, err) + agt := ws.LatestBuild.Resources[0].Agents[0] + + // Insert a provisioner job log + _, err = db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{ + JobID: wbr.Build.JobID, + CreatedAt: []time.Time{dbtime.Now()}, + Source: []database.LogSource{database.LogSourceProvisionerDaemon}, + Level: []database.LogLevel{database.LogLevelInfo}, + Stage: []string{"The World"}, + Output: []string{"Players"}, + }) + require.NoError(t, err) + // Insert an agent log + _, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{ + AgentID: agt.ID, + CreatedAt: dbtime.Now(), + Output: []string{"Bond, James Bond"}, + Level: []database.LogLevel{database.LogLevelInfo}, + LogSourceID: wbr.Build.JobID, + OutputLength: 0o7, + }) + require.NoError(t, err) + + return ws, agt +}